flagsmith-common 2.2.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- common/__init__.py +0 -0
- common/core/__init__.py +6 -0
- common/core/app.py +6 -0
- common/core/cli/__init__.py +0 -0
- common/core/cli/healthcheck.py +120 -0
- common/core/logging.py +24 -0
- common/core/main.py +105 -0
- common/core/management/__init__.py +0 -0
- common/core/management/commands/__init__.py +0 -0
- common/core/management/commands/docgen.py +63 -0
- common/core/management/commands/start.py +61 -0
- common/core/management/commands/waitfordb.py +87 -0
- common/core/metrics.py +25 -0
- common/core/middleware.py +22 -0
- common/core/templates/docgen-metrics.md +22 -0
- common/core/urls.py +17 -0
- common/core/utils.py +239 -0
- common/core/views.py +27 -0
- common/environments/permissions.py +15 -0
- common/features/__init__.py +0 -0
- common/features/multivariate/__init__.py +0 -0
- common/features/multivariate/serializers.py +19 -0
- common/features/serializers.py +68 -0
- common/features/versioning/__init__.py +0 -0
- common/features/versioning/serializers.py +13 -0
- common/gunicorn/__init__.py +0 -0
- common/gunicorn/conf.py +18 -0
- common/gunicorn/constants.py +23 -0
- common/gunicorn/logging.py +120 -0
- common/gunicorn/metrics.py +26 -0
- common/gunicorn/middleware.py +30 -0
- common/gunicorn/utils.py +104 -0
- common/migrations/__init__.py +0 -0
- common/migrations/helpers/__init__.py +9 -0
- common/migrations/helpers/postgres_helpers.py +41 -0
- common/organisations/permissions.py +10 -0
- common/projects/permissions.py +40 -0
- common/prometheus/__init__.py +3 -0
- common/prometheus/utils.py +38 -0
- common/py.typed +0 -0
- common/test_tools/__init__.py +11 -0
- common/test_tools/plugin.py +139 -0
- common/test_tools/types.py +56 -0
- common/test_tools/utils.py +11 -0
- common/types.py +45 -0
- flagsmith_common-2.2.4.dist-info/METADATA +196 -0
- flagsmith_common-2.2.4.dist-info/RECORD +92 -0
- flagsmith_common-2.2.4.dist-info/WHEEL +4 -0
- flagsmith_common-2.2.4.dist-info/entry_points.txt +6 -0
- flagsmith_common-2.2.4.dist-info/licenses/LICENSE +28 -0
- task_processor/__init__.py +0 -0
- task_processor/admin.py +38 -0
- task_processor/apps.py +47 -0
- task_processor/decorators.py +209 -0
- task_processor/exceptions.py +28 -0
- task_processor/health.py +44 -0
- task_processor/managers.py +18 -0
- task_processor/metrics.py +22 -0
- task_processor/migrations/0001_initial.py +44 -0
- task_processor/migrations/0002_healthcheckmodel.py +21 -0
- task_processor/migrations/0003_add_completed_to_task.py +22 -0
- task_processor/migrations/0004_recreate_task_indexes.py +43 -0
- task_processor/migrations/0005_update_conditional_index_conditions.py +45 -0
- task_processor/migrations/0006_auto_20230221_0802.py +45 -0
- task_processor/migrations/0007_add_is_locked.py +23 -0
- task_processor/migrations/0008_add_get_task_to_process_function.py +31 -0
- task_processor/migrations/0009_add_recurring_task_run_first_run_at.py +18 -0
- task_processor/migrations/0010_task_priority.py +27 -0
- task_processor/migrations/0011_add_priority_to_get_tasks_to_process.py +27 -0
- task_processor/migrations/0012_add_locked_at_and_timeout.py +40 -0
- task_processor/migrations/0013_add_last_picked_at.py +34 -0
- task_processor/migrations/__init__.py +0 -0
- task_processor/migrations/sql/0008_get_recurring_tasks_to_process.sql +30 -0
- task_processor/migrations/sql/0008_get_tasks_to_process.sql +30 -0
- task_processor/migrations/sql/0011_get_tasks_to_process.sql +30 -0
- task_processor/migrations/sql/0012_get_recurringtasks_to_process.sql +33 -0
- task_processor/migrations/sql/0013_get_recurringtasks_to_process.sql +33 -0
- task_processor/migrations/sql/__init__.py +0 -0
- task_processor/models.py +237 -0
- task_processor/monitoring.py +12 -0
- task_processor/processor.py +202 -0
- task_processor/py.typed +0 -0
- task_processor/routers.py +55 -0
- task_processor/serializers.py +7 -0
- task_processor/task_registry.py +90 -0
- task_processor/task_run_method.py +7 -0
- task_processor/tasks.py +71 -0
- task_processor/threads.py +128 -0
- task_processor/types.py +18 -0
- task_processor/urls.py +5 -0
- task_processor/utils.py +71 -0
- task_processor/views.py +20 -0
common/gunicorn/utils.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import os
|
|
3
|
+
from functools import lru_cache
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from django.core.handlers.wsgi import WSGIHandler
|
|
7
|
+
from django.core.wsgi import get_wsgi_application
|
|
8
|
+
from django.http import HttpRequest
|
|
9
|
+
from drf_yasg.generators import EndpointEnumerator # type: ignore[import-untyped]
|
|
10
|
+
from environs import Env
|
|
11
|
+
from gunicorn.app.wsgiapp import ( # type: ignore[import-untyped]
|
|
12
|
+
WSGIApplication as GunicornWSGIApplication,
|
|
13
|
+
)
|
|
14
|
+
from gunicorn.config import Config # type: ignore[import-untyped]
|
|
15
|
+
|
|
16
|
+
from common.gunicorn.constants import WSGI_EXTRA_PREFIX
|
|
17
|
+
|
|
18
|
+
env = Env()
|
|
19
|
+
|
|
20
|
+
DEFAULT_ACCESS_LOG_FORMAT = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %({origin}i)s %({access-control-allow-origin}o)s'
|
|
21
|
+
GUNICORN_FLAGSMITH_DEFAULTS = {
|
|
22
|
+
"access_log_format": env.str("ACCESS_LOG_FORMAT", DEFAULT_ACCESS_LOG_FORMAT),
|
|
23
|
+
"accesslog": env.str("ACCESS_LOG_LOCATION", os.devnull),
|
|
24
|
+
"bind": "0.0.0.0:8000",
|
|
25
|
+
"config": "python:common.gunicorn.conf",
|
|
26
|
+
"logger_class": "common.gunicorn.logging.GunicornJsonCapableLogger",
|
|
27
|
+
"statsd_prefix": "flagsmith.api",
|
|
28
|
+
"threads": env.int("GUNICORN_THREADS", 1),
|
|
29
|
+
"timeout": env.int("GUNICORN_TIMEOUT", 30),
|
|
30
|
+
"worker_class": "sync",
|
|
31
|
+
"workers": env.int("GUNICORN_WORKERS", 1),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DjangoWSGIApplication(GunicornWSGIApplication): # type: ignore[misc]
|
|
36
|
+
def __init__(self, options: dict[str, Any] | None) -> None:
|
|
37
|
+
self.options = {
|
|
38
|
+
key: value for key, value in (options or {}).items() if value is not None
|
|
39
|
+
}
|
|
40
|
+
super().__init__()
|
|
41
|
+
|
|
42
|
+
def load_config(self) -> None:
|
|
43
|
+
cfg_settings = self.cfg.settings
|
|
44
|
+
options_items = (
|
|
45
|
+
(key, value)
|
|
46
|
+
for key, value in {**GUNICORN_FLAGSMITH_DEFAULTS, **self.options}.items()
|
|
47
|
+
if key in cfg_settings
|
|
48
|
+
)
|
|
49
|
+
for key, value in options_items:
|
|
50
|
+
self.cfg.set(key.lower(), value)
|
|
51
|
+
self.load_config_from_module_name_or_filename(self.cfg.config)
|
|
52
|
+
|
|
53
|
+
def load_wsgiapp(self) -> WSGIHandler:
|
|
54
|
+
return get_wsgi_application()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def add_arguments(parser: argparse.ArgumentParser) -> None:
|
|
58
|
+
gunicorn_group = parser.add_argument_group("gunicorn")
|
|
59
|
+
_config = Config()
|
|
60
|
+
keys = sorted(_config.settings, key=_config.settings.__getitem__)
|
|
61
|
+
for key in keys:
|
|
62
|
+
_config.settings[key].add_option(gunicorn_group)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run_server(options: dict[str, Any] | None = None) -> None:
|
|
66
|
+
DjangoWSGIApplication(options).run()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@lru_cache
|
|
70
|
+
def get_route_template(route: str) -> str:
|
|
71
|
+
"""
|
|
72
|
+
Convert a Django regex route to a template string that can be
|
|
73
|
+
searched for in the API documentation.
|
|
74
|
+
|
|
75
|
+
e.g.,
|
|
76
|
+
|
|
77
|
+
`"^api/v1/environments/(?P<environment_api_key>[^/.]+)/api-keys/$"` ->
|
|
78
|
+
`"/api/v1/environments/{environment_api_key}/api-keys/"`
|
|
79
|
+
"""
|
|
80
|
+
route_template: str = EndpointEnumerator().get_path_from_regex(route)
|
|
81
|
+
return route_template
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def log_extra(
|
|
85
|
+
request: HttpRequest,
|
|
86
|
+
key: str,
|
|
87
|
+
value: Any,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""
|
|
90
|
+
Store a value in the WSGI request `environ` using a prefixed key.
|
|
91
|
+
|
|
92
|
+
https://peps.python.org/pep-3333/#specification-details
|
|
93
|
+
"...the application is allowed to modify the dictionary in any way it desires"
|
|
94
|
+
"""
|
|
95
|
+
meta_key = f"{WSGI_EXTRA_PREFIX}{key}"
|
|
96
|
+
request.META[meta_key] = value
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_extra(environ: dict[str, Any], key: str) -> Any:
|
|
100
|
+
"""
|
|
101
|
+
Retrieve a value from the WSGI request `environ` using a prefixed key.
|
|
102
|
+
"""
|
|
103
|
+
meta_key = f"{WSGI_EXTRA_PREFIX}{key}"
|
|
104
|
+
return environ.get(meta_key)
|
|
File without changes
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Note: django doesn't support adding submodules to the migrations module directory
|
|
3
|
+
that don't include a Migration class. As such, I've defined this helpers submodule
|
|
4
|
+
and simplified the imports by defining the __all__ attribute.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from common.migrations.helpers.postgres_helpers import PostgresOnlyRunSQL
|
|
8
|
+
|
|
9
|
+
__all__ = ["PostgresOnlyRunSQL"]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from contextlib import suppress
|
|
2
|
+
|
|
3
|
+
from django.db import migrations
|
|
4
|
+
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
|
|
5
|
+
from django.db.migrations.state import ProjectState
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class PostgresOnlyRunSQL(migrations.RunSQL):
|
|
9
|
+
@classmethod
|
|
10
|
+
def from_sql_file(
|
|
11
|
+
cls,
|
|
12
|
+
file_path: str,
|
|
13
|
+
reverse_sql: str = "",
|
|
14
|
+
) -> "PostgresOnlyRunSQL":
|
|
15
|
+
with open(file_path) as forward_sql:
|
|
16
|
+
with suppress(FileNotFoundError):
|
|
17
|
+
with open(reverse_sql) as reverse_sql_file:
|
|
18
|
+
reverse_sql = reverse_sql_file.read()
|
|
19
|
+
return cls(forward_sql.read(), reverse_sql=reverse_sql)
|
|
20
|
+
|
|
21
|
+
def database_forwards(
|
|
22
|
+
self,
|
|
23
|
+
app_label: str,
|
|
24
|
+
schema_editor: BaseDatabaseSchemaEditor,
|
|
25
|
+
from_state: ProjectState,
|
|
26
|
+
to_state: ProjectState,
|
|
27
|
+
) -> None:
|
|
28
|
+
if schema_editor.connection.vendor != "postgresql":
|
|
29
|
+
return
|
|
30
|
+
super().database_forwards(app_label, schema_editor, from_state, to_state)
|
|
31
|
+
|
|
32
|
+
def database_backwards(
|
|
33
|
+
self,
|
|
34
|
+
app_label: str,
|
|
35
|
+
schema_editor: BaseDatabaseSchemaEditor,
|
|
36
|
+
from_state: ProjectState,
|
|
37
|
+
to_state: ProjectState,
|
|
38
|
+
) -> None:
|
|
39
|
+
if schema_editor.connection.vendor != "postgresql":
|
|
40
|
+
return
|
|
41
|
+
super().database_backwards(app_label, schema_editor, from_state, to_state)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
CREATE_PROJECT = "CREATE_PROJECT"
|
|
2
|
+
MANAGE_USER_GROUPS = "MANAGE_USER_GROUPS"
|
|
3
|
+
|
|
4
|
+
ORGANISATION_PERMISSIONS = (
|
|
5
|
+
(CREATE_PROJECT, "Allows the user to create projects in this organisation."),
|
|
6
|
+
(
|
|
7
|
+
MANAGE_USER_GROUPS,
|
|
8
|
+
"Allows the user to manage the groups in the organisation and their members.",
|
|
9
|
+
),
|
|
10
|
+
)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
VIEW_AUDIT_LOG = "VIEW_AUDIT_LOG"
|
|
2
|
+
|
|
3
|
+
# Maintain a list of permissions here
|
|
4
|
+
VIEW_PROJECT = "VIEW_PROJECT"
|
|
5
|
+
CREATE_ENVIRONMENT = "CREATE_ENVIRONMENT"
|
|
6
|
+
DELETE_FEATURE = "DELETE_FEATURE"
|
|
7
|
+
CREATE_FEATURE = "CREATE_FEATURE"
|
|
8
|
+
EDIT_FEATURE = "EDIT_FEATURE"
|
|
9
|
+
MANAGE_SEGMENTS = "MANAGE_SEGMENTS"
|
|
10
|
+
MANAGE_TAGS = "MANAGE_TAGS"
|
|
11
|
+
|
|
12
|
+
# Note that this does not impact change requests in an environment
|
|
13
|
+
MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS = "MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS"
|
|
14
|
+
APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS = "APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS"
|
|
15
|
+
CREATE_PROJECT_LEVEL_CHANGE_REQUESTS = "CREATE_PROJECT_LEVEL_CHANGE_REQUESTS"
|
|
16
|
+
|
|
17
|
+
TAG_SUPPORTED_PERMISSIONS = [DELETE_FEATURE]
|
|
18
|
+
|
|
19
|
+
PROJECT_PERMISSIONS = [
|
|
20
|
+
(VIEW_PROJECT, "View permission for the given project."),
|
|
21
|
+
(CREATE_ENVIRONMENT, "Ability to create an environment in the given project."),
|
|
22
|
+
(DELETE_FEATURE, "Ability to delete features in the given project."),
|
|
23
|
+
(CREATE_FEATURE, "Ability to create features in the given project."),
|
|
24
|
+
(EDIT_FEATURE, "Ability to edit features in the given project."),
|
|
25
|
+
(MANAGE_SEGMENTS, "Ability to manage segments in the given project."),
|
|
26
|
+
(VIEW_AUDIT_LOG, "Allows the user to view the audit logs for this organisation."),
|
|
27
|
+
(
|
|
28
|
+
MANAGE_PROJECT_LEVEL_CHANGE_REQUESTS,
|
|
29
|
+
"Ability to create, delete, and publish change requests associated with a project.",
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
APPROVE_PROJECT_LEVEL_CHANGE_REQUESTS,
|
|
33
|
+
"Ability to approve project level change requests.",
|
|
34
|
+
),
|
|
35
|
+
(
|
|
36
|
+
CREATE_PROJECT_LEVEL_CHANGE_REQUESTS,
|
|
37
|
+
"Ability to create project level change requests.",
|
|
38
|
+
),
|
|
39
|
+
(MANAGE_TAGS, "Allows the user to manage tags in the given project."),
|
|
40
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
|
|
3
|
+
import prometheus_client
|
|
4
|
+
from django.conf import settings
|
|
5
|
+
from prometheus_client.metrics import MetricWrapperBase
|
|
6
|
+
from prometheus_client.multiprocess import MultiProcessCollector
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class Histogram(prometheus_client.Histogram):
|
|
10
|
+
DEFAULT_BUCKETS = settings.PROMETHEUS_HISTOGRAM_BUCKETS
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_registry() -> prometheus_client.CollectorRegistry:
|
|
14
|
+
registry = prometheus_client.CollectorRegistry()
|
|
15
|
+
MultiProcessCollector(registry) # type: ignore[no-untyped-call]
|
|
16
|
+
return registry
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def reload_metrics(*metric_module_names: str) -> None:
|
|
20
|
+
"""
|
|
21
|
+
Clear the registry of all collectors from the given modules
|
|
22
|
+
and reload the modules to register the collectors again.
|
|
23
|
+
|
|
24
|
+
Used in tests to reset the state of the metrics module
|
|
25
|
+
when needed.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
registry = prometheus_client.REGISTRY
|
|
29
|
+
|
|
30
|
+
for module_name in metric_module_names:
|
|
31
|
+
metrics_module = importlib.import_module(module_name)
|
|
32
|
+
|
|
33
|
+
for module_attr in vars(metrics_module).values():
|
|
34
|
+
if isinstance(module_attr, MetricWrapperBase):
|
|
35
|
+
# Unregister the collector from the registry
|
|
36
|
+
registry.unregister(module_attr)
|
|
37
|
+
|
|
38
|
+
importlib.reload(metrics_module)
|
common/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
from functools import partial
|
|
2
|
+
from typing import Generator
|
|
3
|
+
|
|
4
|
+
import prometheus_client
|
|
5
|
+
import pytest
|
|
6
|
+
from prometheus_client.metrics import MetricWrapperBase
|
|
7
|
+
from pyfakefs.fake_filesystem import FakeFilesystem
|
|
8
|
+
from pytest_django.fixtures import SettingsWrapper
|
|
9
|
+
|
|
10
|
+
from common.test_tools.types import (
|
|
11
|
+
AssertMetricFixture,
|
|
12
|
+
RunTasksFixture,
|
|
13
|
+
Snapshot,
|
|
14
|
+
SnapshotFixture,
|
|
15
|
+
)
|
|
16
|
+
from task_processor.task_run_method import TaskRunMethod
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
20
|
+
group = parser.getgroup("snapshot")
|
|
21
|
+
group.addoption(
|
|
22
|
+
"--snapshot-update",
|
|
23
|
+
action="store_true",
|
|
24
|
+
help="Update snapshot files instead of testing against them.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def assert_metric_impl() -> Generator[AssertMetricFixture, None, None]:
|
|
29
|
+
registry = prometheus_client.REGISTRY
|
|
30
|
+
collectors = [*registry._collector_to_names]
|
|
31
|
+
|
|
32
|
+
# Reset registry state
|
|
33
|
+
for collector in collectors:
|
|
34
|
+
if isinstance(collector, MetricWrapperBase):
|
|
35
|
+
collector.clear()
|
|
36
|
+
|
|
37
|
+
def _assert_metric(
|
|
38
|
+
*,
|
|
39
|
+
name: str,
|
|
40
|
+
labels: dict[str, str],
|
|
41
|
+
value: float | int,
|
|
42
|
+
) -> None:
|
|
43
|
+
metric_value = registry.get_sample_value(name, labels)
|
|
44
|
+
assert metric_value == value, (
|
|
45
|
+
f"Metric {name} not found in registry:\n"
|
|
46
|
+
f"{prometheus_client.generate_latest(registry).decode()}"
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
yield _assert_metric
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
assert_metric = pytest.fixture(assert_metric_impl)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.fixture()
|
|
56
|
+
def saas_mode(fs: FakeFilesystem) -> Generator[None, None, None]:
|
|
57
|
+
from common.core.utils import is_saas
|
|
58
|
+
|
|
59
|
+
is_saas.cache_clear()
|
|
60
|
+
fs.create_file("./SAAS_DEPLOYMENT")
|
|
61
|
+
|
|
62
|
+
yield
|
|
63
|
+
|
|
64
|
+
is_saas.cache_clear()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@pytest.fixture()
|
|
68
|
+
def enterprise_mode(fs: FakeFilesystem) -> Generator[None, None, None]:
|
|
69
|
+
from common.core.utils import is_enterprise
|
|
70
|
+
|
|
71
|
+
is_enterprise.cache_clear()
|
|
72
|
+
fs.create_file("./ENTERPRISE_VERSION")
|
|
73
|
+
|
|
74
|
+
yield
|
|
75
|
+
|
|
76
|
+
is_enterprise.cache_clear()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@pytest.fixture()
|
|
80
|
+
def task_processor_mode(settings: SettingsWrapper) -> None:
|
|
81
|
+
settings.TASK_PROCESSOR_MODE = True
|
|
82
|
+
# The setting is supposed to be set before the metrics module is imported,
|
|
83
|
+
# so reload it
|
|
84
|
+
from common.prometheus.utils import reload_metrics
|
|
85
|
+
|
|
86
|
+
reload_metrics("task_processor.metrics")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.fixture(autouse=True)
|
|
90
|
+
def flagsmith_markers_marked(
|
|
91
|
+
request: pytest.FixtureRequest,
|
|
92
|
+
) -> None:
|
|
93
|
+
for marker in request.node.iter_markers():
|
|
94
|
+
if marker.name == "saas_mode":
|
|
95
|
+
request.getfixturevalue("saas_mode")
|
|
96
|
+
if marker.name == "enterprise_mode":
|
|
97
|
+
request.getfixturevalue("enterprise_mode")
|
|
98
|
+
if marker.name == "task_processor_mode":
|
|
99
|
+
request.getfixturevalue("task_processor_mode")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.fixture(name="run_tasks")
|
|
103
|
+
def run_tasks_impl(
|
|
104
|
+
settings: SettingsWrapper,
|
|
105
|
+
transactional_db: None,
|
|
106
|
+
task_processor_mode: None,
|
|
107
|
+
) -> RunTasksFixture:
|
|
108
|
+
settings.TASK_RUN_METHOD = TaskRunMethod.TASK_PROCESSOR
|
|
109
|
+
|
|
110
|
+
from task_processor.processor import run_tasks
|
|
111
|
+
|
|
112
|
+
return partial(run_tasks, database="default")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@pytest.fixture
|
|
116
|
+
def snapshot(request: pytest.FixtureRequest) -> SnapshotFixture:
|
|
117
|
+
"""
|
|
118
|
+
Retrieve a `Snapshot` object getter for the current test.
|
|
119
|
+
The snapshot is stored in the `snapshots` directory next to the test file.
|
|
120
|
+
|
|
121
|
+
Snapshot files are named after the test function name (+ ".txt") by default.
|
|
122
|
+
If a name is provided to the getter, the snapshot will be stored in a file with that name.
|
|
123
|
+
The name is relative to the `snapshots` directory.
|
|
124
|
+
|
|
125
|
+
When `--snapshot-update` is provided to `pytest`:
|
|
126
|
+
- The snapshot will be created if it does not exist.
|
|
127
|
+
- If the comparison is false, the snapshot will be updated with the string it's being compared to in the test,
|
|
128
|
+
and the test will be marked as expected to fail.
|
|
129
|
+
"""
|
|
130
|
+
for_update = request.config.getoption("--snapshot-update")
|
|
131
|
+
snapshot_dir = request.path.parent / "snapshots"
|
|
132
|
+
snapshot_dir.mkdir(exist_ok=True)
|
|
133
|
+
|
|
134
|
+
def _get_snapshot(name: str = "") -> Snapshot:
|
|
135
|
+
snapshot_name = name or f"{request.node.name}.txt"
|
|
136
|
+
snapshot_path = snapshot_dir / snapshot_name
|
|
137
|
+
return Snapshot(snapshot_path, for_update=for_update)
|
|
138
|
+
|
|
139
|
+
return _get_snapshot
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import TYPE_CHECKING, Protocol
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
if TYPE_CHECKING:
|
|
7
|
+
from task_processor.models import TaskRun
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AssertMetricFixture(Protocol):
|
|
11
|
+
def __call__(
|
|
12
|
+
self,
|
|
13
|
+
*,
|
|
14
|
+
name: str,
|
|
15
|
+
labels: dict[str, str],
|
|
16
|
+
value: float | int,
|
|
17
|
+
) -> None: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RunTasksFixture(Protocol):
|
|
21
|
+
def __call__(
|
|
22
|
+
self,
|
|
23
|
+
num_tasks: int,
|
|
24
|
+
) -> "list[TaskRun]": ...
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SnapshotFixture(Protocol):
|
|
28
|
+
def __call__(self, name: str = "") -> "Snapshot": ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Snapshot:
|
|
32
|
+
"""
|
|
33
|
+
Read contents of `path` and make them available for comparison via the `==` operator.
|
|
34
|
+
If the contents are different, and `Snapshot` initialised in update mode,
|
|
35
|
+
(e.g. by running `pytest` with `--snapshot-update`), write the new contents to `path`.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(self, path: Path, for_update: bool) -> None:
|
|
39
|
+
self.path = path
|
|
40
|
+
mode = "r" if not for_update else "w+"
|
|
41
|
+
self.content: str = open(path, encoding="utf-8", mode=mode).read()
|
|
42
|
+
self.for_update = for_update
|
|
43
|
+
|
|
44
|
+
def __eq__(self, other: object) -> bool:
|
|
45
|
+
if self.content == other:
|
|
46
|
+
return True
|
|
47
|
+
if self.for_update and isinstance(other, str):
|
|
48
|
+
with open(self.path, "w", encoding="utf-8") as f:
|
|
49
|
+
f.write(other)
|
|
50
|
+
pytest.xfail(reason=f"Snapshot updated: {self.path}")
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
def __str__(self) -> str:
|
|
54
|
+
return self.content
|
|
55
|
+
|
|
56
|
+
__repr__ = __str__
|
common/types.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
|
|
3
|
+
if typing.TYPE_CHECKING:
|
|
4
|
+
from django.contrib.contenttypes.models import ContentType
|
|
5
|
+
from django.db import models
|
|
6
|
+
|
|
7
|
+
FeatureStateValue: typing.TypeAlias = models.Model
|
|
8
|
+
FeatureSegment: typing.TypeAlias = models.Model
|
|
9
|
+
Condition: typing.TypeAlias = models.Model
|
|
10
|
+
MultivariateFeatureStateValue: typing.TypeAlias = models.Model
|
|
11
|
+
Metadata: typing.TypeAlias = models.Model
|
|
12
|
+
Organisation: typing.TypeAlias = models.Model
|
|
13
|
+
|
|
14
|
+
class SoftDeleteExportableModel(models.Model):
|
|
15
|
+
def hard_delete(self) -> None: ...
|
|
16
|
+
|
|
17
|
+
class Segment(SoftDeleteExportableModel):
|
|
18
|
+
id: int
|
|
19
|
+
version: int | None
|
|
20
|
+
rules = models.ForeignKey("Rule", on_delete=models.CASCADE)
|
|
21
|
+
|
|
22
|
+
def deep_clone(self) -> "Segment": ...
|
|
23
|
+
|
|
24
|
+
class Rule(models.Model):
|
|
25
|
+
def get_segment(self) -> Segment: ...
|
|
26
|
+
|
|
27
|
+
class SegmentRule(Rule):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
class Project(models.Model):
|
|
31
|
+
organisation: "Organisation"
|
|
32
|
+
max_segments_allowed: int
|
|
33
|
+
|
|
34
|
+
class MetadataField(models.Model):
|
|
35
|
+
name: str
|
|
36
|
+
organisation: "Organisation"
|
|
37
|
+
|
|
38
|
+
class MetadataModelField(models.Model):
|
|
39
|
+
field: "MetadataField"
|
|
40
|
+
content_type: ContentType
|
|
41
|
+
|
|
42
|
+
class MetadataModelFieldRequirement(models.Model):
|
|
43
|
+
model_field: "MetadataModelField"
|
|
44
|
+
object_id: int
|
|
45
|
+
content_type: ContentType
|