diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..056de7c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,79 @@ +# stage 1 +FROM python:3.11-alpine AS backend + +ARG USER_UID=1000 +ARG GROUP_UID=1000 + +RUN apk update \ + && apk add --no-cache \ + vim \ + curl \ + gettext + +RUN addgroup -g $USER_UID newsreader && adduser -Du $GROUP_UID -G newsreader newsreader + +RUN mkdir --parents /app/src /app/logs /app/media /app/bin /app/static \ + && chown -R newsreader:newsreader /app + +WORKDIR /app + +USER newsreader + +COPY --chown=newsreader:newsreader uv.lock pyproject.toml /app/ + +COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv + +RUN --mount=type=cache,target=$HOME/.cache/uv \ + uv sync --frozen --no-default-groups --no-install-project + +COPY --chown=newsreader:newsreader ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh + +VOLUME ["/app/logs", "/app/media", "/app/static"] + + + +# stage 2 +FROM node:lts-alpine AS frontend-build + +ARG BUILD_ARG=prod + +WORKDIR /app + +RUN chown node:node /app + +USER node + +COPY --chown=node:node ./package*.json ./webpack.*.js ./babel.config.js /app/ + +RUN --mount=type=cache,target=$HOME/.npm \ + npm ci + +COPY --chown=node:node ./src /app/src + +RUN npm run build:$BUILD_ARG + + + +# stage 3 +FROM backend AS production + +COPY --from=frontend-build --chown=newsreader:newsreader \ + /app/src/newsreader/static /app/src/newsreader/static + +RUN --mount=type=cache,target=$HOME/.cache/uv \ + uv sync --frozen --only-group production --extra sentry + +COPY --chown=newsreader:newsreader ./src /app/src + +# Note that the static volume will have to be recreated to be pre-populated +# correctly with the latest static files. See +# https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container +RUN uv run --no-sync -- src/manage.py collectstatic --noinput + + + +# (optional) stage 4 +FROM backend AS development + +RUN --mount=type=cache,target=$HOME/.cache/uv \ + uv sync --frozen --group development diff --git a/bin/docker-entrypoint.sh b/bin/docker-entrypoint.sh index 0006178..bb473e6 100755 --- a/bin/docker-entrypoint.sh +++ b/bin/docker-entrypoint.sh @@ -1,5 +1,5 @@ #!/bin/bash -/app/.venv/bin/python /app/src/manage.py migrate +uv run --no-sync -- /app/src/manage.py migrate exec "$@" diff --git a/docker-compose.development.yml b/docker-compose.development.yml index d00550b..37236f6 100644 --- a/docker-compose.development.yml +++ b/docker-compose.development.yml @@ -1,18 +1,11 @@ volumes: static-files: - node-modules: services: - celery: - build: - target: development - volumes: - - ./src/:/app/src - django: - build: + build: &app-development-build target: development - command: /app/.venv/bin/python /app/src/manage.py runserver 0.0.0.0:8000 + command: uv run --no-sync -- /app/src/manage.py runserver 0.0.0.0:8000 ports: - "${DJANGO_PORT:-8000}:8000" volumes: @@ -21,12 +14,19 @@ services: stdin_open: true tty: true + celery: + build: + <<: *app-development-build + volumes: + - ./src/:/app/src + webpack: build: + target: frontend-build context: . - dockerfile: ./docker/webpack + args: + BUILD_ARG: "dev" command: npm run build:watch volumes: - ./src/:/app/src - static-files:/app/src/newsreader/static - - node-modules:/app/node_modules diff --git a/docker-compose.production.yml b/docker-compose.production.yml index 46a9c76..24c8cd1 100644 --- a/docker-compose.production.yml +++ b/docker-compose.production.yml @@ -9,7 +9,6 @@ services: django: condition: service_healthy ports: - # Note that --env-file should be used to set these correctly - "${NGINX_HTTP_PORT:-80}:80" volumes: - ./config/nginx/conf.d:/etc/nginx/conf.d diff --git a/docker-compose.yml b/docker-compose.yml index 02f1fab..c348d96 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,33 +4,43 @@ volumes: postgres-data: static-files: -x-db-env: &db-env - POSTGRES_HOST: - POSTGRES_PORT: - POSTGRES_DB: - POSTGRES_USER: - POSTGRES_PASSWORD: +x-db-connection-env: &db-connection-env + POSTGRES_HOST: ${POSTGRES_HOST:-db} + POSTGRES_PORT: ${POSTGRES_PORT:-5432} + POSTGRES_DB: &pg-database ${POSTGRES_DB:-newsreader} + POSTGRES_USER: &pg-user ${POSTGRES_USER:-newsreader} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-newsreader} -x-django-build-env: &django-build-env - <<: *db-env - DJANGO_SECRET_KEY: - DJANGO_SETTINGS_MODULE: +x-db-env: &db-env + <<: *db-connection-env + PGUSER: *pg-user + PGDATABASE: *pg-database x-django-env: &django-env - <<: *django-build-env - VERSION: + <<: *db-connection-env + + ALLOWED_HOSTS: ${ALLOWED_HOSTS:-localhost,127.0.0.1,django} + INTERNAL_IPS: ${INTERNAL_IPS:-localhost,127.0.0.1,django} + + # see token_urlsafe from python's secret module to generate one + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:-Ojg68lYsP3kq2r5JgozUzKVSRFywm17BTMS5iwpLM44} + DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE:-newsreader.conf.production} + + ADMINS: ${ADMINS:-""} + + VERSION: ${VERSION:-""} # Email - EMAIL_HOST: - EMAIL_PORT: - EMAIL_HOST_USER: - EMAIL_HOST_PASSWORD: - EMAIL_USE_TLS: - EMAIL_USE_SSL: - EMAIL_DEFAULT_FROM: + EMAIL_HOST: ${EMAIL_HOST:-localhost} + EMAIL_PORT: ${EMAIL_PORT:-25} + EMAIL_HOST_USER: ${EMAIL_HOST_USER:-""} + EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD:-""} + EMAIL_USE_TLS: ${EMAIL_USE_TLS:-no} + EMAIL_USE_SSL: ${EMAIL_USE_SSL:-no} + EMAIL_DEFAULT_FROM: ${EMAIL_DEFAULT_FROM:-webmaster@localhost} # Sentry - SENTRY_DSN: + SENTRY_DSN: ${SENTRY_DSN:-""} services: db: @@ -38,8 +48,8 @@ services: <<: *db-env image: postgres:15 healthcheck: - # Note that --env-file should be used to set these correctly - test: /usr/bin/pg_isready --username="${POSTGRES_USER}" --dbname="${POSTGRES_DB}" + test: /usr/bin/pg_isready + start_period: 10s interval: 5s timeout: 10s retries: 10 @@ -55,58 +65,23 @@ services: - memcached - -m 64 - celery: - build: - context: . - dockerfile: ./docker/django - target: production - args: - <<: *django-build-env - environment: - <<: *django-env - command: | - /app/.venv/bin/celery --app newsreader - --workdir /app/src/ - worker --loglevel INFO - --concurrency 2 - --beat - --scheduler django - -n worker1@%h - -n worker2@%h - healthcheck: - test: celery --app newsreader status || exit 1 - interval: 10s - timeout: 10s - retries: 5 - depends_on: - rabbitmq: - condition: service_started - memcached: - condition: service_started - db: - condition: service_healthy - django: - condition: service_healthy - volumes: - - logs:/app/logs - django: - build: + build: &app-build context: . - dockerfile: ./docker/django target: production - args: - <<: *django-build-env environment: <<: *django-env - entrypoint: /app/bin/docker-entrypoint.sh + entrypoint: ["/bin/sh", "/app/bin/docker-entrypoint.sh"] command: | - /app/.venv/bin/gunicorn --bind 0.0.0.0:8000 + uv run --no-sync -- + gunicorn + --bind 0.0.0.0:8000 --workers 3 --chdir /app/src/ newsreader.wsgi:application healthcheck: test: /usr/bin/curl --fail http://django:8000 || exit 1 + start_period: 10s interval: 10s timeout: 10s retries: 5 @@ -119,3 +94,33 @@ services: - logs:/app/logs - media:/app/media - static-files:/app/static + + celery: + build: + <<: *app-build + environment: + <<: *django-env + command: | + uv run --no-sync -- + celery + --app newsreader + --workdir /app/src/ + worker --loglevel INFO + --concurrency 2 + --beat + --scheduler django + -n worker1@%h + -n worker2@%h + healthcheck: + test: uv run --no-sync -- celery --app newsreader status || exit 1 + start_period: 10s + interval: 10s + timeout: 10s + retries: 5 + depends_on: + rabbitmq: + condition: service_started + django: + condition: service_healthy + volumes: + - logs:/app/logs diff --git a/docker/django b/docker/django deleted file mode 100644 index 6e079c8..0000000 --- a/docker/django +++ /dev/null @@ -1,102 +0,0 @@ -# stage 1 -FROM python:3.11-bookworm AS backend - -RUN apt-get update && apt-get install --yes --no-install-recommends \ - vim \ - curl \ - gettext \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app -RUN mkdir /app/src -RUN mkdir /app/logs -RUN mkdir /app/media - -COPY uv.lock pyproject.toml /app/ - -COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv -RUN uv sync --frozen --no-default-groups --no-install-project - - -# stage 2 -FROM node:lts AS frontend-build - -WORKDIR /app - -COPY ./*.json ./*.js ./babel.config.js /app/ - -RUN npm ci - -COPY ./src /app/src - -RUN npm run build:prod - - -# stage 3 -FROM python:3.11-bookworm AS production - -RUN apt-get update && apt-get install --yes --no-install-recommends \ - postgresql-client \ - vim \ - curl \ - gettext \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -RUN mkdir /app/logs -RUN mkdir /app/media -RUN mkdir /app/bin - -COPY --from=backend /app/.venv /app/.venv -COPY --from=backend /app/uv.lock /app/pyproject.toml /app/ -COPY --from=backend /bin/uv /bin/uv - -COPY ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh - -COPY --from=frontend-build /app/src/newsreader/static /app/src/newsreader/static - -COPY ./src /app/src - -RUN uv sync --frozen --only-group production --extra sentry - -RUN useradd --no-create-home --uid 1000 newsreader -RUN chown --recursive newsreader:newsreader /app - -USER newsreader - -ARG POSTGRES_HOST -ARG POSTGRES_PORT -ARG POSTGRES_DB -ARG POSTGRES_USER -ARG POSTGRES_PASSWORD -ARG DJANGO_SECRET_KEY -ARG DJANGO_SETTINGS_MODULE - -# Note that the static volume will have to be recreated to be pre-populated -# correctly with the latest static files. See -# https://docs.docker.com/storage/volumes/#populate-a-volume-using-a-container -RUN /app/.venv/bin/python src/manage.py collectstatic --noinput - - -# (optional) stage 4 -FROM python:3.11-bookworm AS development - -RUN apt-get update && apt-get install --yes --no-install-recommends \ - vim \ - curl \ - && rm -rf /var/lib/apt/lists/* - -WORKDIR /app - -RUN mkdir /app/logs -RUN mkdir /app/media -RUN mkdir /app/bin - -COPY --from=backend /app/.venv /app/.venv -COPY --from=backend /app/uv.lock /app/pyproject.toml /app/ -COPY ./bin/docker-entrypoint.sh /app/bin/docker-entrypoint.sh -COPY --from=backend /app/src/ /app/src/ - -COPY --from=backend /bin/uv /bin/uv -RUN uv sync --frozen --group development diff --git a/docker/webpack b/docker/webpack deleted file mode 100644 index 11c3d58..0000000 --- a/docker/webpack +++ /dev/null @@ -1,10 +0,0 @@ -FROM node:lts - -WORKDIR /app -RUN mkdir /app/src - -COPY package*.json webpack.*.js babel.config.js /app/ - -RUN npm install - -COPY ./src /app/src diff --git a/package.json b/package.json index 8e1f60f..d12a88a 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "scripts": { "lint": "npx prettier \"src/newsreader/js/**/*.js\" --check", "format": "npx prettier \"src/newsreader/js/**/*.js\" --write", - "build": "npx webpack --config webpack.dev.babel.js", "build:watch": "npx webpack --config webpack.dev.babel.js --watch", + "build:dev": "npx webpack --config webpack.dev.babel.js", "build:prod": "npx webpack --config webpack.prod.babel.js", "test": "npx jest", "test:watch": "npm test -- --watch" diff --git a/pyproject.toml b/pyproject.toml index 5f0d46a..22ae7fd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.11" dependencies = [ "django~=4.2", "celery~=5.4", - "psycopg", + "psycopg[binary]", "django-axes", "django-celery-beat~=2.7.0", "django-rest-framework", @@ -36,7 +36,7 @@ production = ["gunicorn~=23.0"] sentry = ["sentry-sdk~=2.0"] [tool.uv] -environments = ["sys_platform == "linux""] +environments = ["sys_platform == 'linux'"] default-groups = ["test-tools"] [tool.ruff] @@ -68,7 +68,7 @@ django = ["django"] [tool.coverage.run] source = ["./src/newsreader/"] omit = [ - "**/tests/**" + "**/tests/**", "**/migrations/**", "**/conf/**", "**/apps.py", @@ -77,5 +77,5 @@ omit = [ "**/urls.py", "**/wsgi.py", "**/celery.py", - "**/__init__.py + "**/__init__.py" ] diff --git a/src/newsreader/conf/base.py b/src/newsreader/conf/base.py index 5bee027..b13d732 100644 --- a/src/newsreader/conf/base.py +++ b/src/newsreader/conf/base.py @@ -4,6 +4,8 @@ from pathlib import Path from dotenv import load_dotenv +from newsreader.conf.utils import get_env, get_root_dir + load_dotenv() @@ -15,16 +17,13 @@ except ImportError: DjangoIntegration = None -BASE_DIR = Path(__file__).resolve().parent.parent.parent.parent +BASE_DIR = get_root_dir() DJANGO_PROJECT_DIR = BASE_DIR / "src" / "newsreader" -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/2.2/howto/deployment/checklist/ -# SECURITY WARNING: don"t run with debug turned on in production! DEBUG = False -ALLOWED_HOSTS = ["127.0.0.1", "localhost"] -INTERNAL_IPS = ["127.0.0.1", "localhost"] +ALLOWED_HOSTS = get_env("ALLOWED_HOSTS", split=",", default=["127.0.0.1", "localhost"]) +INTERNAL_IPS = get_env("INTERNAL_IPS", split=",", default=["127.0.0.1", "localhost"]) # Application definition INSTALLED_APPS = [ @@ -48,7 +47,7 @@ INSTALLED_APPS = [ "newsreader.news.collection", ] -SECRET_KEY = os.environ["DJANGO_SECRET_KEY"] +SECRET_KEY = get_env("DJANGO_SECRET_KEY", default="") AUTHENTICATION_BACKENDS = [ "axes.backends.AxesBackend", @@ -73,11 +72,10 @@ FORM_RENDERER = "django.forms.renderers.TemplatesSetting" TEMPLATES = [ { "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], + "DIRS": [DJANGO_PROJECT_DIR / "templates"], "APP_DIRS": True, "OPTIONS": { "context_processors": [ - "django.template.context_processors.debug", "django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", @@ -88,16 +86,14 @@ TEMPLATES = [ WSGI_APPLICATION = "newsreader.wsgi.application" -# Database -# https://docs.djangoproject.com/en/2.2/ref/settings/#databases DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", - "HOST": os.environ["POSTGRES_HOST"], - "PORT": os.environ["POSTGRES_PORT"], - "NAME": os.environ["POSTGRES_DB"], - "USER": os.environ["POSTGRES_USER"], - "PASSWORD": os.environ["POSTGRES_PASSWORD"], + "HOST": get_env("POSTGRES_HOST", default=""), + "PORT": get_env("POSTGRES_PORT", default=""), + "NAME": get_env("POSTGRES_DB", default=""), + "USER": get_env("POSTGRES_USER", default=""), + "PASSWORD": get_env("POSTGRES_PASSWORD", default=""), } } @@ -114,8 +110,6 @@ CACHES = { }, } -# Logging -# https://docs.djangoproject.com/en/2.2/topics/logging/#configuring-logging LOGGING = { "version": 1, "disable_existing_loggers": False, @@ -169,8 +163,6 @@ LOGGING = { }, } -# Password validation -# https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator" @@ -185,8 +177,6 @@ AUTH_USER_MODEL = "accounts.User" LOGIN_REDIRECT_URL = "/" -# Internationalization -# https://docs.djangoproject.com/en/2.2/topics/i18n/ LANGUAGE_CODE = "en-us" TIME_ZONE = "Europe/Amsterdam" @@ -194,20 +184,33 @@ USE_I18N = True USE_L10N = True USE_TZ = True -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/2.2/howto/static-files/ STATIC_URL = "/static/" -STATIC_ROOT = os.path.join(BASE_DIR, "static") -STATICFILES_DIRS = [os.path.join(DJANGO_PROJECT_DIR, "static")] +STATIC_ROOT = BASE_DIR / "static" +STATICFILES_DIRS = ( + DJANGO_PROJECT_DIR / "static", +) -# https://docs.djangoproject.com/en/2.2/ref/settings/#std:setting-STATICFILES_FINDERS STATICFILES_FINDERS = [ "django.contrib.staticfiles.finders.FileSystemFinder", "django.contrib.staticfiles.finders.AppDirectoriesFinder", ] # Email -EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" + +DEFAULT_FROM_EMAIL = get_env( + "EMAIL_DEFAULT_FROM", required=False, default="webmaster@localhost" +) + +EMAIL_HOST = get_env("EMAIL_HOST", required=False, default="localhost") +EMAIL_PORT = get_env("EMAIL_PORT", cast=int, required=False, default=25) + +EMAIL_HOST_USER = get_env("EMAIL_HOST_USER", required=False, default="") +EMAIL_HOST_PASSWORD = get_env("EMAIL_HOST_PASSWORD", required=False, default="") + +EMAIL_USE_TLS = get_env("EMAIL_USE_TLS", required=False, default=False) +EMAIL_USE_SSL = get_env("EMAIL_USE_SSL", required=False, default=False) + # Third party settings AXES_HANDLER = "axes.handlers.cache.AxesCacheHandler" @@ -216,7 +219,6 @@ AXES_FAILURE_LIMIT = 5 AXES_COOLOFF_TIME = 3 # in hours AXES_RESET_ON_SUCCESS = True -# TODO: verify parser works correctly REST_FRAMEWORK = { "DEFAULT_AUTHENTICATION_CLASSES": ( "rest_framework.authentication.SessionAuthentication", @@ -247,7 +249,7 @@ CELERY_BROKER_URL = "amqp://guest@rabbitmq:5672" # Sentry SENTRY_CONFIG = { - "dsn": os.environ.get("SENTRY_DSN"), + "dsn": get_env("SENTRY_DSN", default="", required=False), "send_default_pii": False, "integrations": [DjangoIntegration(), CeleryIntegration()] if DjangoIntegration and CeleryIntegration diff --git a/src/newsreader/conf/ci.py b/src/newsreader/conf/ci.py index 40c4a2f..e69e079 100644 --- a/src/newsreader/conf/ci.py +++ b/src/newsreader/conf/ci.py @@ -1,5 +1,5 @@ from .base import * # noqa: F403 -from .version import get_current_version +from .utils import get_current_version DEBUG = True diff --git a/src/newsreader/conf/dev.py b/src/newsreader/conf/dev.py index d048f6d..2bd7625 100644 --- a/src/newsreader/conf/dev.py +++ b/src/newsreader/conf/dev.py @@ -1,5 +1,5 @@ from .base import * # noqa: F403 -from .version import get_current_version +from .utils import get_current_version SECRET_KEY = "mv4&5#+)-=abz3^&1r^nk_ca6y54--p(4n4cg%z*g&rb64j%wl" @@ -10,6 +10,11 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405 + +TEMPLATES[0]["OPTIONS"]["context_processors"].append( + "django.template.context_processors.debug", +) + # Project settings VERSION = get_current_version() diff --git a/src/newsreader/conf/docker.py b/src/newsreader/conf/docker.py index 0d6e6ee..0bddf32 100644 --- a/src/newsreader/conf/docker.py +++ b/src/newsreader/conf/docker.py @@ -1,8 +1,8 @@ from .base import * # noqa: F403 -from .version import get_current_version +from .utils import get_current_version -ALLOWED_HOSTS = ["django", "127.0.0.1"] +DEBUG = True INSTALLED_APPS += ["debug_toolbar", "django_extensions"] # noqa: F405 @@ -16,7 +16,10 @@ LOGGING["loggers"].update( # noqa: F405 EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" -DEBUG = True + +TEMPLATES[0]["OPTIONS"]["context_processors"].append( + "django.template.context_processors.debug", +) # Project settings VERSION = get_current_version() diff --git a/src/newsreader/conf/production.py b/src/newsreader/conf/production.py index ea22f30..bfe665d 100644 --- a/src/newsreader/conf/production.py +++ b/src/newsreader/conf/production.py @@ -1,49 +1,20 @@ import os +from newsreader.conf.utils import get_env + from .base import * # noqa: F403 -from .version import get_current_version +from .utils import get_current_version DEBUG = False SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") -ALLOWED_HOSTS = ["127.0.0.1", "localhost", "rss.fudiggity.nl", "django"] - ADMINS = [ ("", email) - for email in os.getenv("ADMINS", "").split(",") - if os.environ.get("ADMINS") + for email in get_env("ADMINS", split=",", required=False, default=[]) ] -TEMPLATES = [ - { - "BACKEND": "django.template.backends.django.DjangoTemplates", - "DIRS": [os.path.join(DJANGO_PROJECT_DIR, "templates")], # noqa: F405 - "APP_DIRS": True, - "OPTIONS": { - "context_processors": [ - "django.template.context_processors.request", - "django.contrib.auth.context_processors.auth", - "django.contrib.messages.context_processors.messages", - ] - }, - } -] - -# Email -EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" -DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_DEFAULT_FROM", "webmaster@localhost") - -EMAIL_HOST = os.environ.get("EMAIL_HOST", "localhost") -EMAIL_PORT = os.environ.get("EMAIL_PORT", 25) - -EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "") -EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "") - -EMAIL_USE_TLS = bool(os.environ.get("EMAIL_USE_TLS")) -EMAIL_USE_SSL = bool(os.environ.get("EMAIL_USE_SSL")) - # Project settings VERSION = get_current_version(debug=False) ENVIRONMENT = "production" diff --git a/src/newsreader/conf/utils.py b/src/newsreader/conf/utils.py new file mode 100644 index 0000000..2e084f6 --- /dev/null +++ b/src/newsreader/conf/utils.py @@ -0,0 +1,85 @@ +import logging +import os +import subprocess + +from pathlib import Path +from typing import Any, Iterable, Type + + +logger = logging.getLogger(__name__) + + +def get_env( + name: str, + cast: Type = str, + required: bool = True, + default: Any = None, + split: str = "" +) -> Any: + if cast is not str and split: + raise TypeError(f"Split is not possible with {cast}") + + value = os.getenv(name) + + if not value: + if required: + logger.warning(f"Missing environment variable: {name}") + + return default + + bool_mapping = {"yes": True, "true": True, "false": False, "no": False} + + if cast is bool: + _value = bool_mapping.get(value.lower()) + + if not value: + raise ValueError(f"Unknown boolean value: {_value}") + + return _value + + value = value if not cast else cast(value) + return value if not split else value.split(split) + + +def get_current_version(debug: bool = True) -> str: + version = get_env("VERSION", required=False) + + if version: + return version + + if debug: + try: + output = subprocess.check_output( + ["git", "show", "--no-patch", "--format=%H"], universal_newlines=True + ) + return output.strip() + except (subprocess.CalledProcessError, OSError): + return "" + + try: + output = subprocess.check_output( + ["git", "describe", "--tags"], universal_newlines=True + ) + return output.strip() + except (subprocess.CalledProcessError, OSError): + return "" + + +ROOT_MARKERS = ("pyproject.toml", "package.json", "README.md", "CHANGELOG.md") + + +def get_root_dir() -> Path: + file = Path(__file__) + return _traverse_dirs(file.parent, ROOT_MARKERS) + + +def _traverse_dirs(path: Path, root_markers: Iterable[str]) -> Path: + if path.parent == path: + raise OSError("Root directory detected") + + files = (file.name for file in path.iterdir()) + + if not any((marker for marker in root_markers if marker in files)): + return _traverse_dirs(path.parent, root_markers) + + return path diff --git a/src/newsreader/conf/version.py b/src/newsreader/conf/version.py deleted file mode 100644 index 806adef..0000000 --- a/src/newsreader/conf/version.py +++ /dev/null @@ -1,26 +0,0 @@ -import os -import subprocess - - -def get_current_version(debug=True): - version = os.environ.get("VERSION") - - if version: - return version - - if debug: - try: - output = subprocess.check_output( - ["git", "show", "--no-patch", "--format=%H"], universal_newlines=True - ) - return output.strip() - except (subprocess.CalledProcessError, OSError): - return "" - - try: - output = subprocess.check_output( - ["git", "describe", "--tags"], universal_newlines=True - ) - return output.strip() - except (subprocess.CalledProcessError, OSError): - return ""