diff --git a/schema.graphql b/schema.graphql index 443d4ed..5184ce9 100644 --- a/schema.graphql +++ b/schema.graphql @@ -38,6 +38,7 @@ type Environment { packages: [Package!]! state: State tags: [String!]! + hidden: Boolean! requested: DateTime buildStart: DateTime buildDone: DateTime @@ -72,6 +73,12 @@ type Group { name: String! } +union HiddenResponse = HiddenSuccess | InvalidInputError | EnvironmentNotFoundError + +type HiddenSuccess implements Success { + message: String! +} + type InvalidInputError implements Error { message: String! } @@ -95,6 +102,7 @@ type SchemaMutation { createEnvironment(env: EnvironmentInput!): CreateResponse! deleteEnvironment(name: String!, path: String!): DeleteResponse! addTag(name: String!, path: String!, tag: String!): AddTagResponse! + setHidden(name: String!, path: String!, hidden: Boolean!): HiddenResponse! createFromModule(file: Upload!, modulePath: String!, environmentPath: String!): CreateResponse! updateFromModule(file: Upload!, modulePath: String!, environmentPath: String!): UpdateResponse! } diff --git a/softpack_core/artifacts.py b/softpack_core/artifacts.py index ab2558c..96fae0d 100644 --- a/softpack_core/artifacts.py +++ b/softpack_core/artifacts.py @@ -156,12 +156,27 @@ def spec(self) -> Box: map(lambda p: Package.from_name(p), info.packages) ) + metadata = self.metadata() + + info["tags"] = getattr(metadata, "tags", []) + info["hidden"] = getattr(metadata, "hidden", False) + info["force_hidden"] = getattr(metadata, "force_hidden", False) + + return info + + def metadata(self) -> Box: + """Returns the metadata for an Environment. + + Should contain keys: + - tags: list[string] + - hidden: boolean + - force_hidden: boolean + """ meta = Box() if Artifacts.meta_file in self.obj: meta = Box.from_yaml(self.obj[Artifacts.meta_file].data) - info["tags"] = getattr(meta, "tags", []) - return info + return meta def __iter__(self) -> Iterator["Artifacts.Object"]: """A generator for returning items under an artifacts. diff --git a/softpack_core/graphql.py b/softpack_core/graphql.py index 227af70..3a846e0 100644 --- a/softpack_core/graphql.py +++ b/softpack_core/graphql.py @@ -14,7 +14,6 @@ from typing_extensions import Type from .api import API -from .app import app from .schemas.base import BaseSchema from .schemas.environment import EnvironmentSchema from .schemas.groups import GroupsSchema @@ -32,16 +31,6 @@ class GraphQL(API): ] commands = Typer(help="GraphQL commands.") - @staticmethod - @commands.command("query", help="Execute a GraphQL query.") - def query_command() -> None: - """Execute a GraphQL query. - - Returns: - None. - """ - app.echo("GraphQL Query") - class Schema(strawberry.Schema): """GraphQL Schema class.""" diff --git a/softpack_core/schemas/environment.py b/softpack_core/schemas/environment.py index 4421083..c25697e 100644 --- a/softpack_core/schemas/environment.py +++ b/softpack_core/schemas/environment.py @@ -17,6 +17,7 @@ import starlette.datastructures import strawberry import yaml +from box import Box from fastapi import UploadFile from strawberry.file_uploads import Upload @@ -57,6 +58,11 @@ class AddTagSuccess(Success): """Successfully added tag to environment.""" +@strawberry.type +class HiddenSuccess(Success): + """Successfully set hidden status on environment.""" + + @strawberry.type class DeleteEnvironmentSuccess(Success): """Environment successfully deleted.""" @@ -124,6 +130,15 @@ class BuilderError(Error): ], ) +HiddenResponse = strawberry.union( + "HiddenResponse", + [ + HiddenSuccess, + InvalidInputError, + EnvironmentNotFoundError, + ], +) + DeleteResponse = strawberry.union( "DeleteResponse", [ @@ -306,6 +321,7 @@ class Environment: packages: list[Package] state: Optional[State] tags: list[str] + hidden: bool requested: Optional[datetime.datetime] = None build_start: Optional[datetime.datetime] = None @@ -364,6 +380,9 @@ def from_artifact(cls, obj: Artifacts.Object) -> Optional["Environment"]: """ try: spec = obj.spec() + if spec.force_hidden: + return None + return Environment( id=obj.oid, name=obj.name, @@ -374,6 +393,7 @@ def from_artifact(cls, obj: Artifacts.Object) -> Optional["Environment"]: readme=spec.get("readme", ""), type=spec.get("type", ""), tags=spec.tags, + hidden=spec.hidden, ) except KeyError: return None @@ -586,12 +606,63 @@ def add_tag( return AddTagSuccess(message="Tag already present") tags.add(tag) - metadata = yaml.dump({"tags": sorted(tags)}) + metadata = cls.read_metadata(path, name) + metadata.tags = sorted(tags) + + cls.store_metadata(environment_path, metadata) + + return AddTagSuccess(message="Tag successfully added") + + @classmethod + def read_metadata(cls, path: str, name: str) -> Box: + """Read an environments metadata. + + This method returns the metadata for an environment with the given + path and name. + """ + arts = artifacts.get(Path(path), name) + + if arts is not None: + return arts.metadata() + + return Box() + + @classmethod + def store_metadata(cls, environment_path: Path, metadata: Box) -> None: + """Store an environments metadata. + + This method writes the given metadata to the repo for the + environment path given. + """ tree_oid = artifacts.create_file( - environment_path, artifacts.meta_file, metadata, overwrite=True + environment_path, + artifacts.meta_file, + metadata.to_yaml(), + overwrite=True, ) - artifacts.commit_and_push(tree_oid, "create environment folder") - return AddTagSuccess(message="Tag successfully added") + + artifacts.commit_and_push(tree_oid, "update metadata") + + @classmethod + def set_hidden( + cls, name: str, path: str, hidden: bool + ) -> HiddenResponse: # type: ignore + """This method sets the hidden status for the given environment.""" + environment_path = Path(path, name) + response: Optional[Error] = cls.check_env_exists(environment_path) + if response is not None: + return response + + metadata = cls.read_metadata(path, name) + + if metadata.get("hidden") == hidden: + return HiddenSuccess(message="Hidden metadata already set") + + metadata.hidden = hidden + + cls.store_metadata(environment_path, metadata) + + return HiddenSuccess(message="Hidden metadata set") @classmethod def delete(cls, name: str, path: str) -> DeleteResponse: # type: ignore @@ -847,6 +918,7 @@ class Mutation: createEnvironment: CreateResponse = Environment.create # type: ignore deleteEnvironment: DeleteResponse = Environment.delete # type: ignore addTag: AddTagResponse = Environment.add_tag # type: ignore + setHidden: HiddenResponse = Environment.set_hidden # type: ignore # writeArtifact: WriteArtifactResponse = ( # type: ignore # Environment.write_artifact # ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 0cca742..8bdbcbf 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -43,7 +43,7 @@ def httpx_post(mocker): @pytest.fixture -def testable_env_input(mocker) -> EnvironmentInput: +def testable_env_input(mocker) -> EnvironmentInput: # type: ignore ad = new_test_artifacts() artifacts: Artifacts = ad["artifacts"] user = ad["test_user"] diff --git a/tests/integration/test_environment.py b/tests/integration/test_environment.py index ca00fc3..949ff4f 100644 --- a/tests/integration/test_environment.py +++ b/tests/integration/test_environment.py @@ -24,6 +24,7 @@ EnvironmentAlreadyExistsError, EnvironmentInput, EnvironmentNotFoundError, + HiddenSuccess, InvalidInputError, Package, State, @@ -553,3 +554,52 @@ def test_tagging(httpx_post, testable_env_input: EnvironmentInput) -> None: example_env = Environment.iter()[0] assert example_env.tags == ["second test", "test"] + + +def test_hidden(httpx_post, testable_env_input: EnvironmentInput) -> None: + example_env = Environment.iter()[0] + assert not example_env.hidden + name, path = example_env.name, example_env.path + + result = Environment.set_hidden(name, path, True) + assert isinstance(result, HiddenSuccess) + assert result.message == "Hidden metadata set" + example_env = Environment.iter()[0] + assert example_env.hidden + + result = Environment.set_hidden(name, path, True) + assert isinstance(result, HiddenSuccess) + assert result.message == "Hidden metadata already set" + example_env = Environment.iter()[0] + assert example_env.hidden + + result = Environment.set_hidden(name, path, False) + assert isinstance(result, HiddenSuccess) + assert result.message == "Hidden metadata set" + example_env = Environment.iter()[0] + assert not example_env.hidden + + result = Environment.set_hidden(name, path, False) + assert isinstance(result, HiddenSuccess) + assert result.message == "Hidden metadata already set" + example_env = Environment.iter()[0] + assert not example_env.hidden + + result = Environment.set_hidden(name, path, True) + assert isinstance(result, HiddenSuccess) + assert result.message == "Hidden metadata set" + example_env = Environment.iter()[0] + assert example_env.hidden + + +def test_force_hidden( + httpx_post, testable_env_input: EnvironmentInput +) -> None: + first_env = Environment.iter()[0] + metadata = Environment.read_metadata(first_env.path, first_env.name) + metadata.force_hidden = True + Environment.store_metadata(Path(first_env.path, first_env.name), metadata) + + new_first = Environment.iter()[0] + + assert first_env.path != new_first.path or first_env.name != new_first.name diff --git a/tests/integration/test_groups.py b/tests/integration/test_groups.py index 1827e19..01b2da6 100644 --- a/tests/integration/test_groups.py +++ b/tests/integration/test_groups.py @@ -5,6 +5,7 @@ """ import getpass +import os import ldap @@ -12,7 +13,7 @@ def test_groups(mocker) -> None: - username = getpass.getuser() + username = os.environ.get("LDAP_USER", getpass.getuser()) groups = list(Group.from_username(username)) assert len(groups) assert groups[0].name diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index c9b0e64..4000028 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -5,15 +5,8 @@ """ -import time -from multiprocessing import Process -from threading import Thread - -import httpx import pytest -import requests from fastapi.testclient import TestClient -from typer.testing import CliRunner from softpack_core.app import app @@ -21,49 +14,3 @@ @pytest.fixture def client() -> TestClient: return TestClient(app.router) - - -class CLI: - def __init__(self): - self.runner = CliRunner() - - def invoke(self, *args, **kwargs): - return self.runner.invoke(app.commands, *args, **kwargs) - - -@pytest.fixture -def cli() -> CLI: - return CLI() - - -def service_run(): - cli = CLI() - cli.invoke(["service", "run"]) - - -@pytest.fixture -def service_factory(): - def create_service(module): - service = module(target=service_run, daemon=True) - service.start() - while True: - try: - response = requests.get(app.url()) - if response.status_code == httpx.codes.OK: - break - except requests.ConnectionError: - time.sleep(0.1) - continue - return service - - return create_service - - -@pytest.fixture -def service(service_factory): - return service_factory(Process) - - -@pytest.fixture -def service_thread(service_factory): - return service_factory(Thread) diff --git a/tests/unit/test_graphql.py b/tests/unit/test_graphql.py deleted file mode 100644 index 3336b61..0000000 --- a/tests/unit/test_graphql.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Copyright (c) 2023 Genome Research Ltd. - -This source code is licensed under the MIT license found in the -LICENSE file in the root directory of this source tree. -""" - - -from softpack_core.graphql import GraphQL - - -def test_environment_build_command(service_thread, cli) -> None: - response = cli.invoke(GraphQL.command("query")) - assert response.stdout == "GraphQL Query\n" diff --git a/tests/unit/test_service.py b/tests/unit/test_service.py index 85f9eb4..a41f2cf 100644 --- a/tests/unit/test_service.py +++ b/tests/unit/test_service.py @@ -4,14 +4,28 @@ LICENSE file in the root directory of this source tree. """ +import multiprocessing +from time import sleep + import httpx from box import Box from softpack_core import __version__ from softpack_core.app import app +from softpack_core.service import ServiceAPI -def test_service_run(service_thread) -> None: - response = httpx.get(app.url()) +def test_service_run() -> None: + run = multiprocessing.Process(target=ServiceAPI.run) + run.start() + while True: + try: + response = httpx.get(app.url()) + break + except httpx.RequestError: + if not run.is_alive(): + raise Exception("Service failed to start.") + sleep(5) + run.terminate() status = Box(response.json()) assert status.softpack.core.version == __version__