Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 11 additions & 8 deletions .github/workflows/linting-and-formatting.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
name: Linting & formatting
name: Python Checks and Unit Tests

on:
pull_request:

jobs:
test:
runs-on: ubuntu-latest
name: Linting and Formatting
name: Python Checks and Unit Tests

steps:
- name: Cloning repo
Expand All @@ -20,10 +20,13 @@ jobs:
python-version: 3.12

- name: Install Dependencies
run: poetry install
run: poetry install --with dev

- name: Run Linters
run: |
poetry run black --check .
poetry run isort --check-only --diff .
poetry run flake8
- name: Check for missing migrations
run: poetry run python manage.py makemigrations --no-input --dry-run --check

- name: Check for new typing errors
run: poetry run mypy .

- name: Run Tests
run: poetry run pytest
47 changes: 27 additions & 20 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
repos:
- repo: https://github.com/PyCQA/isort
rev: 5.13.2
hooks:
- id: isort
name: isort (python)
- repo: https://github.com/psf/black
rev: 24.4.2
hooks:
- id: black
language_version: python3
- repo: https://github.com/pycqa/flake8
rev: 7.1.0
hooks:
- id: flake8
- repo: https://github.com/python-poetry/poetry
rev: 1.7.1
hooks:
- id: poetry-check
- id: poetry-lock
args: ['--check']
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.9.7
hooks:
# Run the linter.
- id: ruff
args: [--fix]
# Run the formatter.
- id: ruff-format
- repo: https://github.com/python-poetry/poetry
rev: 2.1.1
hooks:
- id: poetry-check
- id: poetry-lock
- repo: local
hooks:
- id: python-typecheck
name: python-typecheck
language: system
entry: poetry run mypy .
require_serial: true
pass_filenames: false
types: [python]

ci:
skip: [python-typecheck]
autoupdate_commit_msg: "ci: pre-commit autoupdate"
17 changes: 17 additions & 0 deletions manage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env python
import os
import sys

if __name__ == "__main__":
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.dev")

try:
from django.core.management import execute_from_command_line
except ImportError:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
)

execute_from_command_line(sys.argv)
620 changes: 503 additions & 117 deletions poetry.lock

Large diffs are not rendered by default.

95 changes: 56 additions & 39 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
[tool.poetry]
name = "flagsmith_common"
version = "1.4.2"
version = "1.5.0"
description = "A repository for including code that is required in multiple flagsmith repositories"
authors = ["Matthew Elwell <[email protected]>"]
readme = "README.md"
packages = [{ include = "common"}]
packages = [{ include = "common", from = "src" }]

[tool.poetry.dependencies]
python = "^3.10"
Expand All @@ -13,49 +13,66 @@ djangorestframework = "*"
drf-writable-nested = "*"
flagsmith-flag-engine = "*"
djangorestframework-recursive = "*"
django-health-check = "^3.18.3"

[tool.poetry.group.dev.dependencies]
pre-commit = "*"
flake8 = "*"
black = "*"
isort = "*"
ruff = "*"
pytest = "^8.3.4"
pyfakefs = "^5.7.4"
pytest-django = "^4.10.0"
mypy = "^1.15.0"
django-stubs = "^5.1.3"
djangorestframework-stubs = "^3.15.3"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

[tool.black]
[tool.pytest.ini_options]
addopts = ['--ds=settings.dev', '-vvvv', '-p', 'no:warnings']
console_output_style = 'count'

[tool.ruff]
line-length = 88
target-version = ['py311', 'py312']
include = '\.pyi?$'
exclude = '''
/(
\.eggs
| \.git
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
| migrations
)/
'''

[tool.isort]
use_parentheses=true
multi_line_output=3
include_trailing_comma=true
line_length=79
known_first_party=['flagsmith','api','app','core','features','environments']
known_third_party=[
'django',
'rest_framework',
'saml2',
'drf_yasg2',
'pytest',
'rest_framework_recursive',
]
skip = ['migrations', '.venv']
indent-width = 4
target-version = "py311"
extend-exclude = ["migrations"]

[tool.ruff.format]
# Like Black, use double quotes for strings.
quote-style = "double"

# Like Black, indent with spaces, rather than tabs.
indent-style = "space"

# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false

# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"

# Enable auto-formatting of code examples in docstrings. Markdown,
# reStructuredText code/literal blocks and doctests are all supported.
docstring-code-format = true

# Set the line length limit used when formatting code snippets in
# docstrings.
docstring-code-line-length = "dynamic"

[tool.ruff.lint]
# Establish parity with flake8 + isort
select = ["C901", "E4", "E7", "E9", "F", "I", "W"]
ignore = []
fixable = ["ALL"]
unfixable = []

[tool.mypy]
plugins = ["mypy_django_plugin.main"]
strict = true

[tool.django-stubs]
django_settings_module = "settings.dev"

[tool.drf-stubs]
enabled = true
File renamed without changes.
15 changes: 15 additions & 0 deletions settings/dev.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
INSTALLED_APPS = [
"django.contrib.auth",
"django.contrib.contenttypes",
]
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": "common",
}
}

EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
SENDGRID_API_KEY = ""
AWS_SES_REGION_ENDPOINT = ""
SEGMENT_RULES_CONDITIONS_LIMIT = 0
File renamed without changes.
File renamed without changes.
13 changes: 13 additions & 0 deletions src/common/app/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from django.urls import include, re_path

from common.app import views

urlpatterns = [
re_path(r"^version/?", views.version_info),
re_path(r"^health/liveness/?", views.version_info),
re_path(r"^health/readiness/?", include("health_check.urls", namespace="health")),
re_path(r"^health", include("health_check.urls", namespace="health-deprecated")),
# Aptible health checks must be on /healthcheck and cannot redirect
# see https://www.aptible.com/docs/core-concepts/apps/connecting-to-apps/app-endpoints/https-endpoints/health-checks
re_path(r"^healthcheck", include("health_check.urls", namespace="health-aptible")),
]
87 changes: 87 additions & 0 deletions src/common/app/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import json
import pathlib
from functools import lru_cache
from typing import NotRequired, TypedDict

from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AbstractBaseUser
from django.db.models import Manager

UNKNOWN = "unknown"
VERSIONS_INFO_FILE_LOCATION = ".versions.json"


class SelfHostedData(TypedDict):
has_users: bool
has_logins: bool


class VersionInfo(TypedDict):
ci_commit_sha: str
image_tag: str
has_email_provider: bool
is_enterprise: bool
is_saas: bool
self_hosted_data: SelfHostedData | None
package_versions: NotRequired[dict[str, str]]


def is_enterprise() -> bool:
return pathlib.Path("./ENTERPRISE_VERSION").exists()


def is_saas() -> bool:
return pathlib.Path("./SAAS_DEPLOYMENT").exists()


def has_email_provider() -> bool:
match settings.EMAIL_BACKEND:
case "django.core.mail.backends.smtp.EmailBackend":
return settings.EMAIL_HOST_USER is not None
case "sgbackend.SendGridBackend":
return settings.SENDGRID_API_KEY is not None
case "django_ses.SESBackend":
return settings.AWS_SES_REGION_ENDPOINT is not None
case _:
return False


@lru_cache
def get_version_info() -> VersionInfo:
"""Reads the version info baked into src folder of the docker container"""
_is_saas = is_saas()
version_json: VersionInfo = {
"ci_commit_sha": _get_file_contents("./CI_COMMIT_SHA"),
"image_tag": UNKNOWN,
"has_email_provider": has_email_provider(),
"is_enterprise": is_enterprise(),
"is_saas": _is_saas,
"self_hosted_data": None,
}

manifest_versions_content: str = _get_file_contents(VERSIONS_INFO_FILE_LOCATION)

if manifest_versions_content != UNKNOWN:
manifest_versions = json.loads(manifest_versions_content)
version_json["package_versions"] = manifest_versions
version_json["image_tag"] = manifest_versions["."]

if not _is_saas:
user_objects: Manager[AbstractBaseUser] = getattr(get_user_model(), "objects")

version_json["self_hosted_data"] = {
"has_users": user_objects.exists(),
"has_logins": user_objects.filter(last_login__isnull=False).exists(),
}

return version_json


def _get_file_contents(file_path: str) -> str:
"""Attempts to read a file from the filesystem and return the contents"""
try:
with open(file_path) as f:
return f.read().replace("\n", "")
except FileNotFoundError:
return UNKNOWN
12 changes: 12 additions & 0 deletions src/common/app/views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import logging

from django.http import JsonResponse
from rest_framework.request import Request

from common.app import utils

logger = logging.getLogger(__name__)


def version_info(request: Request) -> JsonResponse:
return JsonResponse(utils.get_version_info())
Empty file.
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import typing

from django.apps import apps
from rest_framework import serializers

if typing.TYPE_CHECKING:
from common.types import MultivariateFeatureStateValue # noqa: F401


class MultivariateFeatureStateValueSerializer(serializers.ModelSerializer):
class MultivariateFeatureStateValueSerializer(
serializers.ModelSerializer["MultivariateFeatureStateValue"]
):
class Meta:
model = apps.get_model("multivariate", "MultivariateFeatureStateValue")
fields = (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import typing

from django.apps import apps
from drf_writable_nested.serializers import WritableNestedModelSerializer
from rest_framework import serializers
Expand All @@ -6,14 +8,19 @@
MultivariateFeatureStateValueSerializer,
)

if typing.TYPE_CHECKING:
from common.types import FeatureSegment, FeatureStateValue # noqa: F401


class FeatureStateValueSerializer(serializers.ModelSerializer):
class FeatureStateValueSerializer(serializers.ModelSerializer["FeatureStateValue"]):
class Meta:
model = apps.get_model("features", "FeatureStateValue")
fields = ("type", "string_value", "integer_value", "boolean_value")


class CreateSegmentOverrideFeatureSegmentSerializer(serializers.ModelSerializer):
class CreateSegmentOverrideFeatureSegmentSerializer(
serializers.ModelSerializer["FeatureSegment"]
):
class Meta:
model = apps.get_model("features", "FeatureSegment")
fields = ("id", "segment", "priority", "uuid")
Expand Down
Empty file.
Loading