diff --git a/.github/workflows/backend_sumo_prod.yml b/.github/workflows/backend_sumo_prod.yml new file mode 100644 index 000000000..f1e839139 --- /dev/null +++ b/.github/workflows/backend_sumo_prod.yml @@ -0,0 +1,63 @@ +name: integration + +on: + push: + pull_request: + branches: + - main + release: + types: + - published + # workflow_dispatch: + # schedule: + # - cron: "48 4 * * *" + +jobs: + sumo_prod: + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write + + steps: + - uses: actions/checkout@v4 + + - name: 🤖 Get shared key from Sumo + working-directory: ./backend_py/primary + env: + SHARED_KEY_SUMO_PROD: ${{ secrets.SHARED_KEY_DROGON_READ_PROD }} + run: | + if [ ${#SHARED_KEY_SUMO_PROD} -eq 0 ]; then + echo "Error: SHARED_KEY_SUMO_PROD is empty. Stopping the action." + exit 1 + fi + mkdir ~/.sumo + echo $SHARED_KEY_SUMO_PROD > ~/.sumo/9e5443dd-3431-4690-9617-31eed61cb55a.sharedkey + + - name: 🐍 Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.11" + cache: pip + + - name: 📦 Install poetry and dependencies + working-directory: ./backend_py/primary + run: | + pip install --upgrade pip + pip install poetry + poetry config virtualenvs.create false + poetry lock --check --no-update # Check lock file is consistent with pyproject.toml + poetry install --with dev + + - name: 🤖 Run tests + working-directory: ./backend_py/primary + env: + WEBVIZ_CLIENT_SECRET: 0 + WEBVIZ_SMDA_SUBSCRIPTION_KEY: 0 + WEBVIZ_SMDA_RESOURCE_SCOPE: 0 + WEBVIZ_VDS_HOST_ADDRESS: 0 + WEBVIZ_ENTERPRISE_SUBSCRIPTION_KEY: 0 + WEBVIZ_SSDL_RESOURCE_SCOPE: 0 + WEBVIZ_SUMU_ENV: prod + run: | + pytest -s --timeout=300 ./tests/integration diff --git a/.github/workflows/webviz.yml b/.github/workflows/webviz.yml index 2fbc2b3cd..bcfddb13c 100644 --- a/.github/workflows/webviz.yml +++ b/.github/workflows/webviz.yml @@ -106,7 +106,7 @@ jobs: black --check primary/ tests/ pylint primary/ tests/ bandit --recursive primary/ - mypy primary/ tests/ + mypy primary/ - name: 🤖 Run tests working-directory: ./backend_py/primary diff --git a/backend_py/primary/poetry.lock b/backend_py/primary/poetry.lock index fda2cdab3..ec8568acf 100644 --- a/backend_py/primary/poetry.lock +++ b/backend_py/primary/poetry.lock @@ -2310,13 +2310,13 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2)", "pytest- [[package]] name = "pluggy" -version = "1.0.0" +version = "1.5.0" description = "plugin and hook calling mechanisms for python" optional = false -python-versions = ">=3.6" +python-versions = ">=3.8" files = [ - {file = "pluggy-1.0.0-py2.py3-none-any.whl", hash = "sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"}, - {file = "pluggy-1.0.0.tar.gz", hash = "sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159"}, + {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, + {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, ] [package.extras] @@ -2628,23 +2628,55 @@ diagrams = ["jinja2", "railroad-diagrams"] [[package]] name = "pytest" -version = "7.3.1" +version = "8.3.2" description = "pytest: simple powerful testing with Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, - {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, + {file = "pytest-8.3.2-py3-none-any.whl", hash = "sha256:4ba08f9ae7dcf84ded419494d229b48d0903ea6407b030eaec46df5e6a73bba5"}, + {file = "pytest-8.3.2.tar.gz", hash = "sha256:c132345d12ce551242c87269de812483f5bcc87cdbb4722e48487ba194f9fdce"}, ] [package.dependencies] colorama = {version = "*", markers = "sys_platform == \"win32\""} iniconfig = "*" packaging = "*" -pluggy = ">=0.12,<2.0" +pluggy = ">=1.5,<2" [package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-asyncio" +version = "0.24.0" +description = "Pytest support for asyncio" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_asyncio-0.24.0-py3-none-any.whl", hash = "sha256:a811296ed596b69bf0b6f3dc40f83bcaf341b155a269052d82efa2b25ac7037b"}, + {file = "pytest_asyncio-0.24.0.tar.gz", hash = "sha256:d081d828e576d85f875399194281e92bf8a68d60d72d1a2faf2feddb6c46b276"}, +] + +[package.dependencies] +pytest = ">=8.2,<9" + +[package.extras] +docs = ["sphinx (>=5.3)", "sphinx-rtd-theme (>=1.0)"] +testing = ["coverage (>=6.2)", "hypothesis (>=5.7.1)"] + +[[package]] +name = "pytest-timeout" +version = "2.3.1" +description = "pytest plugin to abort hanging tests" +optional = false +python-versions = ">=3.7" +files = [ + {file = "pytest-timeout-2.3.1.tar.gz", hash = "sha256:12397729125c6ecbdaca01035b9e5239d4db97352320af155b3f5de1ba5165d9"}, + {file = "pytest_timeout-2.3.1-py3-none-any.whl", hash = "sha256:68188cb703edfc6a18fad98dc25a3c61e9f24d644b0b70f33af545219fc7813e"}, +] + +[package.dependencies] +pytest = ">=7.0.0" [[package]] name = "python-dateutil" @@ -3513,4 +3545,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "9e4c2116a592ae012bb3be220132f9465b018c80ebfa3b66c963f3c944965fcb" +content-hash = "04674259ffe1cd2f39ca6fe760075e1432ebf7f37c4f45174bcfbe8445139c29" diff --git a/backend_py/primary/primary/main.py b/backend_py/primary/primary/main.py index 441fd3e8a..62e35d58f 100644 --- a/backend_py/primary/primary/main.py +++ b/backend_py/primary/primary/main.py @@ -14,7 +14,7 @@ from primary.middleware.add_process_time_to_server_timing_middleware import AddProcessTimeToServerTimingMiddleware from primary.routers.correlations.router import router as correlations_router from primary.routers.dev.router import router as dev_router -from primary.routers.explore import router as explore_router +from primary.routers.explore.router import router as explore_router from primary.routers.general import router as general_router from primary.routers.graph.router import router as graph_router from primary.routers.grid3d.router import router as grid3d_router diff --git a/backend_py/primary/primary/routers/explore.py b/backend_py/primary/primary/routers/explore/router.py similarity index 74% rename from backend_py/primary/primary/routers/explore.py rename to backend_py/primary/primary/routers/explore/router.py index effda0837..e64b35411 100644 --- a/backend_py/primary/primary/routers/explore.py +++ b/backend_py/primary/primary/routers/explore/router.py @@ -8,43 +8,21 @@ from primary.services.sumo_access.sumo_inspector import SumoInspector from primary.services.utils.authenticated_user import AuthenticatedUser -router = APIRouter() - - -class FieldInfo(BaseModel): - field_identifier: str - - -class CaseInfo(BaseModel): - uuid: str - name: str - status: str - user: str +from . import schemas - -class EnsembleInfo(BaseModel): - name: str - realization_count: int - - -class EnsembleDetails(BaseModel): - name: str - field_identifier: str - case_name: str - case_uuid: str - realizations: Sequence[int] +router = APIRouter() @router.get("/fields") async def get_fields( authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), -) -> List[FieldInfo]: +) -> List[schemas.FieldInfo]: """ Get list of fields """ sumo_inspector = SumoInspector(authenticated_user.get_sumo_access_token()) field_ident_arr = await sumo_inspector.get_fields_async() - ret_arr = [FieldInfo(field_identifier=field_ident.identifier) for field_ident in field_ident_arr] + ret_arr = [schemas.FieldInfo(field_identifier=field_ident.identifier) for field_ident in field_ident_arr] return ret_arr @@ -53,14 +31,14 @@ async def get_fields( async def get_cases( authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), field_identifier: str = Query(description="Field identifier"), -) -> List[CaseInfo]: +) -> List[schemas.CaseInfo]: """Get list of cases for specified field""" sumo_inspector = SumoInspector(authenticated_user.get_sumo_access_token()) case_info_arr = await sumo_inspector.get_cases_async(field_identifier=field_identifier) - ret_arr: List[CaseInfo] = [] + ret_arr: List[schemas.CaseInfo] = [] - ret_arr = [CaseInfo(uuid=ci.uuid, name=ci.name, status=ci.status, user=ci.user) for ci in case_info_arr] + ret_arr = [schemas.CaseInfo(uuid=ci.uuid, name=ci.name, status=ci.status, user=ci.user) for ci in case_info_arr] return ret_arr @@ -69,14 +47,12 @@ async def get_cases( async def get_ensembles( authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), case_uuid: str = Path(description="Sumo case uuid"), -) -> List[EnsembleInfo]: +) -> List[schemas.EnsembleInfo]: """Get list of ensembles for a case""" case_inspector = CaseInspector.from_case_uuid(authenticated_user.get_sumo_access_token(), case_uuid) iteration_info_arr = await case_inspector.get_iterations_async() - print(iteration_info_arr) - - return [EnsembleInfo(name=it.name, realization_count=it.realization_count) for it in iteration_info_arr] + return [schemas.EnsembleInfo(name=it.name, realization_count=it.realization_count) for it in iteration_info_arr] @router.get("/cases/{case_uuid}/ensembles/{ensemble_name}") @@ -84,7 +60,7 @@ async def get_ensemble_details( authenticated_user: AuthenticatedUser = Depends(AuthHelper.get_authenticated_user), case_uuid: str = Path(description="Sumo case uuid"), ensemble_name: str = Path(description="Ensemble name"), -) -> EnsembleDetails: +) -> schemas.EnsembleDetails: """Get more detailed information for an ensemble""" case_inspector = CaseInspector.from_case_uuid(authenticated_user.get_sumo_access_token(), case_uuid) @@ -95,7 +71,7 @@ async def get_ensemble_details( if len(field_identifiers) != 1: raise NotImplementedError("Multiple field identifiers not supported") - return EnsembleDetails( + return schemas.EnsembleDetails( name=ensemble_name, case_name=case_name, case_uuid=case_uuid, diff --git a/backend_py/primary/primary/routers/explore/schemas.py b/backend_py/primary/primary/routers/explore/schemas.py new file mode 100644 index 000000000..61adf457d --- /dev/null +++ b/backend_py/primary/primary/routers/explore/schemas.py @@ -0,0 +1,27 @@ +from typing import List, Sequence + +from pydantic import BaseModel + + +class FieldInfo(BaseModel): + field_identifier: str + + +class CaseInfo(BaseModel): + uuid: str + name: str + status: str + user: str + + +class EnsembleInfo(BaseModel): + name: str + realization_count: int + + +class EnsembleDetails(BaseModel): + name: str + field_identifier: str + case_name: str + case_uuid: str + realizations: Sequence[int] diff --git a/backend_py/primary/primary/services/sumo_access/_helpers.py b/backend_py/primary/primary/services/sumo_access/_helpers.py index faa902c30..0f5863453 100644 --- a/backend_py/primary/primary/services/sumo_access/_helpers.py +++ b/backend_py/primary/primary/services/sumo_access/_helpers.py @@ -11,7 +11,10 @@ def create_sumo_client(access_token: str) -> SumoClient: - sumo_client = SumoClient(env=config.SUMO_ENV, token=access_token, interactive=False) + if access_token == "DUMMY_TOKEN_FOR_TESTING": # nosec bandit B105 + sumo_client = SumoClient(env=config.SUMO_ENV, interactive=False) + else: + sumo_client = SumoClient(env=config.SUMO_ENV, token=access_token, interactive=False) return sumo_client diff --git a/backend_py/primary/pyproject.toml b/backend_py/primary/pyproject.toml index f28c866de..6566b51fe 100644 --- a/backend_py/primary/pyproject.toml +++ b/backend_py/primary/pyproject.toml @@ -32,11 +32,13 @@ server_schemas = {path = "../libs/server_schemas", develop = true} [tool.poetry.group.dev.dependencies] black = "^22.12.0" pylint = "^2.15.10" -pytest = "^7.2.1" +pytest = "^8.3.2" mypy = "^1.9.0" bandit = "^1.7.5" types-requests = "^2.31.0.1" types-redis = "^4.6.0" +pytest-timeout = "^2.3.1" +pytest-asyncio = "^0.24.0" [build-system] @@ -63,5 +65,6 @@ disallow_untyped_defs = true [tool.pytest.ini_options] pythonpath = ["."] filterwarnings = "ignore::DeprecationWarning:pkg_resources" - +asyncio_mode="auto" +asyncio_default_fixture_loop_scope="session" diff --git a/backend_py/primary/tests/integration/conftest.py b/backend_py/primary/tests/integration/conftest.py new file mode 100644 index 000000000..45c07080c --- /dev/null +++ b/backend_py/primary/tests/integration/conftest.py @@ -0,0 +1,43 @@ +import os +from dataclasses import dataclass + +import pytest + +from primary.services.utils.authenticated_user import AuthenticatedUser, AccessTokens + + +@dataclass +class SumoTestEnsemble: + field_identifier: str + case_uuid: str + case_name: str + ensemble_name: str + + +@pytest.fixture(name="sumo_test_ensemble_ahm", scope="session") +def fixture_sumo_test_ensemble_ahm() -> SumoTestEnsemble: + return SumoTestEnsemble( + field_identifier="DROGON", + case_name="webviz_ahm_case", + case_uuid="485041ce-ad72-48a3-ac8c-484c0ed95cf8", + ensemble_name="iter-0", + ) + + +@pytest.fixture(name="sumo_test_ensemble_design", scope="session") +def fixture_sumo_test_ensemble_design() -> SumoTestEnsemble: + return SumoTestEnsemble( + field_identifier="DROGON", + case_name="01_drogon_design", + case_uuid="b89873c8-6f4d-40e5-978c-afc47beb2a26", + ensemble_name="iter-0", + ) + + +@pytest.fixture(name="test_user", scope="session") +def fixture_test_user() -> AuthenticatedUser: + token = "DUMMY_TOKEN_FOR_TESTING" + tokens = AccessTokens( + sumo_access_token=token, graph_access_token=None, smda_access_token=None, ssdl_access_token=None + ) + return AuthenticatedUser(user_id="test_user", username="test_user", access_tokens=tokens) diff --git a/backend_py/primary/tests/integration/routers/explore/test_explore.py b/backend_py/primary/tests/integration/routers/explore/test_explore.py new file mode 100644 index 000000000..53e1e4625 --- /dev/null +++ b/backend_py/primary/tests/integration/routers/explore/test_explore.py @@ -0,0 +1,32 @@ +from primary.routers.explore import router +from primary.routers.explore import schemas + + +async def test_get_fields(test_user, sumo_test_ensemble_ahm) -> None: + fields = await router.get_fields(test_user) + assert all(isinstance(f, schemas.FieldInfo) for f in fields) + assert any(f.field_identifier == sumo_test_ensemble_ahm.field_identifier for f in fields) + + +async def test_get_cases(test_user, sumo_test_ensemble_ahm) -> None: + cases = await router.get_cases(test_user, sumo_test_ensemble_ahm.field_identifier) + assert all(isinstance(c, schemas.CaseInfo) for c in cases) + assert any(c.uuid == sumo_test_ensemble_ahm.case_uuid for c in cases) + + +async def test_get_ensembles(test_user, sumo_test_ensemble_ahm) -> None: + ensembles = await router.get_ensembles(test_user, sumo_test_ensemble_ahm.case_uuid) + assert all(isinstance(e, schemas.EnsembleInfo) for e in ensembles) + assert any(e.name == sumo_test_ensemble_ahm.ensemble_name for e in ensembles) + + +async def test_get_ensemble_details(test_user, sumo_test_ensemble_ahm) -> None: + ensemble_details = await router.get_ensemble_details( + test_user, sumo_test_ensemble_ahm.case_uuid, sumo_test_ensemble_ahm.ensemble_name + ) + assert isinstance(ensemble_details, schemas.EnsembleDetails) + assert ensemble_details.name == sumo_test_ensemble_ahm.ensemble_name + assert ensemble_details.field_identifier == sumo_test_ensemble_ahm.field_identifier + assert ensemble_details.case_uuid == sumo_test_ensemble_ahm.case_uuid + assert ensemble_details.case_name == sumo_test_ensemble_ahm.case_name + assert len(ensemble_details.realizations) == 100 diff --git a/backend_py/primary/tests/integration/routers/timeseries/test_get_realizations_vector_data.py b/backend_py/primary/tests/integration/routers/timeseries/test_get_realizations_vector_data.py new file mode 100644 index 000000000..4f7796f1b --- /dev/null +++ b/backend_py/primary/tests/integration/routers/timeseries/test_get_realizations_vector_data.py @@ -0,0 +1,61 @@ +import pytest +import numpy as np + +from primary.routers.timeseries import router +from primary.routers.timeseries import schemas + + +@pytest.mark.parametrize( + ["frequency", "date_count", "expected_mean"], + [ + (schemas.Frequency.DAILY, 913, 4003818.89), + (schemas.Frequency.WEEKLY, 132, 4024404.62), + (schemas.Frequency.MONTHLY, 31, 4000047.83), + (schemas.Frequency.YEARLY, 4, 4445506.56), + ], +) +async def test_get_realizations_vector_data_dates( + test_user, sumo_test_ensemble_ahm, frequency, date_count, expected_mean +) -> None: + + realization_data = await router.get_realizations_vector_data( + None, + test_user, + sumo_test_ensemble_ahm.case_uuid, + sumo_test_ensemble_ahm.ensemble_name, + "FOPT", + frequency, + ) + + # check the first realization + first_real_results = realization_data[0] + assert isinstance(first_real_results, schemas.VectorRealizationData) + assert len(first_real_results.timestamps_utc_ms) == date_count + assert np.isclose(np.mean(first_real_results.values), expected_mean, atol=1e-5) + + +@pytest.mark.parametrize( + ["realizations", "real_count", "expected_mean"], + [ + (None, 100, 3945757.89), + ([0, 1, 2, 3, 4, 5], 6, 4074376.18), + ([0, 10, 99], 3, 4384017.10), + ], +) +async def test_get_realizations_vector_data_realizations( + test_user, sumo_test_ensemble_ahm, realizations, real_count, expected_mean +) -> None: + + realization_data = await router.get_realizations_vector_data( + None, + test_user, + sumo_test_ensemble_ahm.case_uuid, + sumo_test_ensemble_ahm.ensemble_name, + "FOPT", + schemas.Frequency.YEARLY, + realizations, + ) + + assert len(realization_data) == real_count + values_mean = [np.mean(realization.values) for realization in realization_data] + assert np.isclose(np.mean(values_mean), expected_mean, atol=1e-5) diff --git a/backend_py/primary/tests/integration/routers/timeseries/test_get_vector_list.py b/backend_py/primary/tests/integration/routers/timeseries/test_get_vector_list.py new file mode 100644 index 000000000..b42110206 --- /dev/null +++ b/backend_py/primary/tests/integration/routers/timeseries/test_get_vector_list.py @@ -0,0 +1,12 @@ +from primary.routers.timeseries import router +from primary.routers.timeseries import schemas + + +async def test_get_vector_list(test_user, sumo_test_ensemble_ahm) -> None: + + vector_list = await router.get_vector_list( + None, test_user, sumo_test_ensemble_ahm.case_uuid, sumo_test_ensemble_ahm.ensemble_name + ) + + assert len(vector_list) == 786 + assert isinstance(vector_list[0], schemas.VectorDescription) diff --git a/backend_py/primary/tests/integration/services/conftest.py b/backend_py/primary/tests/integration/services/conftest.py deleted file mode 100644 index f29f4599c..000000000 --- a/backend_py/primary/tests/integration/services/conftest.py +++ /dev/null @@ -1,18 +0,0 @@ -import os - -import pytest -from sumo.wrapper import SumoClient - - -@pytest.fixture(name="sumo_token") -def fixture_sumo_token() -> str: - token = os.getenv("SUMO_TOKEN") - if token is None: - client = SumoClient(env="dev") - token = client.authenticate() - return token - - -@pytest.fixture(name="sumo_case_uuid") -def fixture_sumo_case_uuid() -> str: - return "10f41041-2c17-4374-a735-bb0de62e29dc" diff --git a/backend_py/primary/tests/integration/services/sumo_access/test_parameter_access.py b/backend_py/primary/tests/integration/services/sumo_access/test_parameter_access.py deleted file mode 100644 index 0d51e6f2a..000000000 --- a/backend_py/primary/tests/integration/services/sumo_access/test_parameter_access.py +++ /dev/null @@ -1,19 +0,0 @@ -import pytest - -from services.sumo_access.parameter_access import ParameterAccess - - -@pytest.fixture(name="parameter_access_instance") -def get_parameter_access_instance(sumo_token: str, sumo_case_uuid: str) -> ParameterAccess: - return ParameterAccess.from_case_uuid(sumo_token, sumo_case_uuid, "iter-0") - - -def test_init(parameter_access_instance: ParameterAccess, sumo_case_uuid: str) -> None: - assert len(parameter_access_instance.case_collection) == 1 - assert parameter_access_instance.case_collection[0].uuid == sumo_case_uuid - - -async def test_get_parameters_and_sensitivities(parameter_access_instance: ParameterAccess) -> None: - parameters_and_sensitivities = await parameter_access_instance.get_parameters_and_sensitivities() - assert len(parameters_and_sensitivities.parameters) == 113 - assert len(parameters_and_sensitivities.sensitivities) == 1