diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5b4cde..062a545 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,6 @@ jobs: - name: Lint with Ruff run: | poetry run ruff check crategen/ - if: ${{ success() }} - name: Type check with Mypy run: | @@ -42,4 +41,4 @@ jobs: - name: Run tests run: | - poetry run pytest --cov=crategen \ No newline at end of file + poetry run pytest --cov=crategen diff --git a/crategen/converters/utils.py b/crategen/converters/utils.py index 4f1a99a..9c0697e 100644 --- a/crategen/converters/utils.py +++ b/crategen/converters/utils.py @@ -1,13 +1,15 @@ """Utility functions for handling data conversion.""" import datetime +import os +import re def convert_to_iso8601(timestamp): """Convert a given timestamp to ISO 8601 format. Handles multiple formats including RFC 3339, ISO 8601 with and without fractional seconds. - + Args: timestamp (str): The timestamp to be converted. @@ -16,15 +18,49 @@ def convert_to_iso8601(timestamp): """ if timestamp: formats = [ - "%Y-%m-%dT%H:%M:%S.%fZ", - "%Y-%m-%dT%H:%M:%SZ", - "%Y-%m-%dT%H:%M:%S%z", + "%Y-%m-%dT%H:%M:%S.%fZ", + "%Y-%m-%dT%H:%M:%SZ", + "%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z", ] for fmt in formats: try: - return datetime.datetime.strptime(timestamp, fmt).isoformat() + "Z" + return datetime.datetime.strptime(timestamp, fmt).isoformat("T") + "Z" except ValueError: continue return None return None + + +# This function does not have to rock solid, it supposed to help users not restrict them +# And due to the difficulty in validating all posible types of file paths it has been not been written to be very stringent +def is_absolute_path(path): + """Checks if a given path is an absolute path, including support for + Windows paths, Amazon S3 paths, and URL-like paths. + + Args: + path: The path string to check. + + Returns: + True if the path is an absolute path, False otherwise. + """ + # Windows absolute paths + if re.match(r"^[a-zA-Z0-9]+:\\", path): + path_after_protocol = path[path.index(":\\") + 2] + return bool(path_after_protocol) + + # UNC paths + if re.match(r"^\\\\", path): + path_after_protocol = path[path.index("\\") + 2] + return bool(path_after_protocol) + + # URL-like paths and paths with similar protocols like amazon s3 paths + if re.match(r"^[a-zA-Z0-9]+://", path): + path_after_protocol = path[path.index("://") + 3] + return bool(path_after_protocol) + + # POSIX absolute paths (Linux/macOS) + if os.path.isabs(path): + return True + + return False diff --git a/crategen/models/tes_models.py b/crategen/models/tes_models.py index 491e2cc..7bc8bec 100644 --- a/crategen/models/tes_models.py +++ b/crategen/models/tes_models.py @@ -1,13 +1,12 @@ """Each model in this module conforms to the corresponding TES model names as specified by the GA4GH schema (https://ga4gh.github.io/task-execution-schemas/docs/).""" -import os -from datetime import datetime from enum import Enum from typing import Optional from pydantic import AnyUrl, BaseModel, root_validator, validator +from rfc3339_validator import validate_rfc3339 # type: ignore -from ..converters.utils import convert_to_iso8601 +from ..converters.utils import is_absolute_path class TESFileType(str, Enum): @@ -79,16 +78,19 @@ class TESExecutorLog(BaseModel): Reference: https://ga4gh.github.io/task-execution-schemas/docs/#operation/GetTask """ - start_time: Optional[datetime] = None - end_time: Optional[datetime] = None + start_time: Optional[str] = None + end_time: Optional[str] = None stdout: Optional[str] = None stderr: Optional[str] = None exit_code: int - @validator("start_time", "end_time", pre=True, always=True) - def validate_datetime(cls, value): - """Convert start and end times to RFC 3339 format.""" - return convert_to_iso8601(value) + @validator("start_time", "end_time") + def validate_datetime(cls, value, field): + """Check correct datetime format""" + if(validate_rfc3339(value)): + return value + else: + raise ValueError(f"The '{field.name}' property must be in the rfc3339 format") class TESExecutor(BaseModel): @@ -119,8 +121,8 @@ class TESExecutor(BaseModel): @validator("stdin", "stdout") def validate_stdin_stdin(cls, value, field): """Ensure that 'stdin' and 'stdout' are absolute paths.""" - if value and not os.path.isabs(value): - raise ValueError(f"The '{field.name}' attribute must contain an absolute path.") + if value and not is_absolute_path(value): + raise ValueError(f"The '{field.name}' property must be an absolute path.") return value @@ -160,33 +162,33 @@ class TESInput(BaseModel): name: Optional[str] = None description: Optional[str] = None - url: Optional[AnyUrl] + url: Optional[AnyUrl] = None path: str - type: Optional[TESFileType] = None + type: Optional[TESFileType] = TESFileType.FILE content: Optional[str] = None @root_validator() def validate_content_and_url(cls, values): - """If content is set url should be ignored. - - If content is not set then url should be present. + """- If content is set url should be ignored. + - If content is not set then url should be present. """ - content_is_set = values.get("content") and values.get("content").strip() - url_is_set = values.get("url") and values.get("url").strip() + content_is_set = bool(values.get("content") and values.get("content").strip()) + url_is_set = bool(values.get("url") and values.get("url").strip()) if content_is_set: values["url"] = None - elif not url_is_set: + elif not url_is_set and not content_is_set: + print("the url", values.get("path")) raise ValueError( - "The 'url' attribute is required when the 'content' attribute is empty" + "Either the 'url' or 'content' properties must be set" ) return values @validator("path") def validate_path(cls, value): """Validate that the path is an absolute path.""" - if not os.path.isabs(value): - raise ValueError("The 'path' attribute must contain an absolute path.") + if not is_absolute_path(value): + raise ValueError("The 'path' property must be an absolute path.") return value @@ -197,7 +199,6 @@ class TESOutput(BaseModel): name: User-provided name of output file description: Optional users provided description field, can be used for documentation. url: URL for the file to be copied by the TES server after the task is complete - path_prefix: The path prefix used when 'path' contains wildcards. path: Path of the file inside the container. Must be an absolute path. type: The type of output (e.g., FILE, DIRECTORY). @@ -207,17 +208,14 @@ class TESOutput(BaseModel): name: Optional[str] = None description: Optional[str] = None url: AnyUrl - path_prefix: Optional[str] = None path: str - type: Optional[TESFileType] = None + type: Optional[TESFileType] = TESFileType.FILE @validator("path") - def validate_path(cls, value, values): + def validate_path(cls, value): """Ensure that 'path' is an absolute path and handle wildcards.""" - if not os.path.isabs(value): - raise ValueError("The 'path' attribute must contain an absolute path.") - if any(char in value for char in ['*', '?', '[', ']']) and not values.get("path_prefix"): - raise ValueError("When 'path' contains wildcards, 'path_prefix' is required.") + if not is_absolute_path(value): + raise ValueError("The 'path' property must be an absolute path.") return value @@ -231,23 +229,24 @@ class TESTaskLog(BaseModel): end_time: When the task ended, in RFC 3339 format. outputs: Information about all output files. Directory outputs are flattened into separate items. system_logs: System logs are any logs the system decides are relevant, which are not tied directly to an Executor process. Content is implementation specific: format, size, etc. - ignore_error: If true, errors in this executor will be ignored. Reference: [https://ga4gh.github.io/task-execution-schemas/docs/#operation/GetTask](https://ga4gh.github.io/task-execution-schemas/docs/#operation/GetTask) """ logs: list[TESExecutorLog] - metadata: Optional[dict[str, str]] - start_time: Optional[datetime] - end_time: Optional[datetime] + metadata: Optional[dict[str, str]] = None + start_time: Optional[str] = None + end_time: Optional[str] = None outputs: list[TESOutputFileLog] - system_logs: Optional[list[str]] - ignore_error: Optional[bool] = False + system_logs: Optional[list[str]] = None @validator("start_time", "end_time", pre=True, always=True) - def validate_datetime(cls, value): - """Convert start and end times to RFC 3339 format.""" - return convert_to_iso8601(value) + def validate_datetime(cls, value, field): + """Check correct datetime format""" + if(validate_rfc3339(value)): + return value + else: + raise ValueError(f"The '{field.name}' property must be in the rfc3339 format") class TESData(BaseModel): @@ -273,7 +272,7 @@ class TESData(BaseModel): id: str name: Optional[str] = None description: Optional[str] = None - creation_time: Optional[datetime] = None + creation_time: Optional[str] = None state: Optional[TESState] = TESState.UNKNOWN inputs: Optional[list[TESInput]] = None outputs: Optional[list[TESOutput]] = None @@ -282,3 +281,10 @@ class TESData(BaseModel): volumes: Optional[list[str]] = None logs: Optional[list[TESTaskLog]] = None tags: Optional[dict[str, str]] = None + + @validator("creation_time") + def validate_datetime(value, field): + if(validate_rfc3339(value)): + return value + else: + raise ValueError(f"The '{field.name}' property must be in the rfc3339 format") \ No newline at end of file diff --git a/poetry.lock b/poetry.lock index 3d2faaa..128722f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1028,6 +1028,20 @@ urllib3 = ">=1.21.1,<3" socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +description = "A pure python RFC3339 validator" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +files = [ + {file = "rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa"}, + {file = "rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b"}, +] + +[package.dependencies] +six = "*" + [[package]] name = "rich" version = "13.7.1" @@ -1227,6 +1241,17 @@ files = [ {file = "shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de"}, ] +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] + [[package]] name = "snowballstemmer" version = "2.2.0" @@ -1514,4 +1539,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = "^3.11" -content-hash = "9f7932bdeb9776273203e9e28772b2253eb5587dc9a85f4be3895ecddc8ec1c9" +content-hash = "542210fedff136eaf29c7bc7cc2a48a26a70ed8bbcb07b49d5b5e9f5324748e7" diff --git a/pyproject.toml b/pyproject.toml index dcae087..160c34e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ requests = "^2.25.1" pytest = "^8.3.1" pytest-cov = "^5.0.0" pytest-mock = "^3.14.0" +rfc3339-validator = "^0.1.4" [tool.poetry.dev-dependencies] pre-commit = "^2.13.0" @@ -61,9 +62,13 @@ skips = [ [tool.ruff] exclude = [ - "tests/*", - "tests/unit/*", - "crategen/*" + ".git", + "/.pytest_cache", + "__pycache__", + "build", + "_build", + "dist", + ".env", ] indent-width = 4 @@ -76,7 +81,6 @@ quote-style = "double" [tool.ruff.lint] select = [ "B", # flake8-bugbear - "D", # pydocstyle "E", # pycodestyle "F", # Pyflakes "I", # isort @@ -84,7 +88,7 @@ select = [ "SIM", # flake8-simplify "UP", # pyupgrade ] -ignore = ["E501"] +ignore = ["E501", "E203"] fixable = ["ALL"] [tool.ruff.lint.pydocstyle] diff --git a/tests/unit/test_tes_models.py b/tests/unit/test_tes_models.py index 95235f6..d5a4a73 100644 --- a/tests/unit/test_tes_models.py +++ b/tests/unit/test_tes_models.py @@ -1,257 +1,260 @@ -"""Unit tests for the TES models.""" - +"""TES UNIT TESTS""" import pytest -from pydantic import ValidationError from crategen.models.tes_models import ( TESData, TESExecutor, - TESFileType, + TESExecutorLog, TESInput, TESOutput, - TESResources, + TESOutputFileLog, + TESTaskLog, ) -EXPECTED_CPU_CORES = 2 -EXPECTED_RAM_GB = 4.0 -EXPECTED_DISK_GB = 10.0 -EXPECTED_PREEMPTIBLE = False - -def test_tes_executor_minimal(): - """Test TESExecutor model with minimal required fields.""" - executor = TESExecutor( - image="python:3.8-slim", - command=["python", "script.py"] - ) - assert executor.image == "python:3.8-slim" - assert executor.command == ["python", "script.py"] - assert executor.workdir is None - assert executor.stdout is None - assert executor.stderr is None - assert executor.stdin is None - assert executor.env is None - assert executor.ignore_error is False # Since default is False - -def test_tes_input_with_url(): - """Test TESInput model with a valid URL and absolute path.""" - input_data = TESInput( - name="Test Input", - description="An example input file.", - url="https://raw.githubusercontent.com/elixir-cloud-aai/CrateGen/refs/heads/main/README.md", - path="/data/input/README.md", - type=TESFileType.FILE, - ) - assert input_data.url == "https://raw.githubusercontent.com/elixir-cloud-aai/CrateGen/refs/heads/main/README.md" - assert input_data.path == "/data/input/README.md" - assert input_data.type == TESFileType.FILE - - -def test_tes_input_with_content(): - """Test TESInput model with inline content instead of a URL.""" - input_data = TESInput( - name="Inline Input", - description="An input with inline content.", - content="Sample data content.", - path="/data/input/inline.txt", - type=TESFileType.FILE, - ) - assert input_data.content == "Sample data content." - assert input_data.url is None - assert input_data.path == "/data/input/inline.txt" - - -def test_tes_input_missing_url_and_content(): - """Test TESInput model when neither URL nor content is provided.""" - with pytest.raises(ValidationError) as exc_info: - TESInput( - name="Invalid Input", - description="An input missing both URL and content.", - path="/data/input/missing.txt", - type=TESFileType.FILE, - ) - assert "The 'url' attribute is required when the 'content' attribute is empty" in str(exc_info.value) - - -def test_tes_input_with_relative_path(): - """Test TESInput model with a relative path (should raise ValidationError).""" - with pytest.raises(ValidationError) as exc_info: - TESInput( - name="Relative Path Input", - description="An input with a relative path.", - url="https://raw.githubusercontent.com/elixir-cloud-aai/CrateGen/refs/heads/main/README.md", - path="data/input/README.md", - type=TESFileType.FILE, - ) - assert "The 'path' attribute must contain an absolute path." in str(exc_info.value) - - -def test_tes_input_content_and_url_conflict(): - """Test TESInput model when both content and URL are provided (URL should be ignored).""" - input_data = TESInput( - name="Input with Content and URL", - description="Input with both content and URL.", - url="https://example.com/should_be_ignored.txt", - content="This content should override the URL.", - path="/data/input/content.txt", - type=TESFileType.FILE, - ) - assert input_data.content == "This content should override the URL." - assert input_data.url is None # URL should be set to None - - -def test_tes_output_valid(): - """Test TESOutput model with valid data.""" - output_data = TESOutput( - name="Test Output", - description="An example output file.", - url="https://raw.githubusercontent.com/elixir-cloud-aai/CrateGen/refs/heads/main/LICENSE", - path="/data/output/LICENSE", - type=TESFileType.FILE, - ) - assert output_data.url == "https://raw.githubusercontent.com/elixir-cloud-aai/CrateGen/refs/heads/main/LICENSE" - assert output_data.path == "/data/output/LICENSE" - assert output_data.type == TESFileType.FILE - - -def test_tes_output_with_relative_path(): - """Test TESOutput model with a relative path (should raise ValidationError).""" - with pytest.raises(ValidationError) as exc_info: - TESOutput( - name="Relative Path Output", - description="An output with a relative path.", - url="https://raw.githubusercontent.com/elixir-cloud-aai/CrateGen/refs/heads/main/LICENSE", - path="data/output/LICENSE", - type=TESFileType.FILE, - ) - assert "The 'path' attribute must contain an absolute path." in str(exc_info.value) - - -def test_tes_executor_valid(): - """Test TESExecutor model with valid data.""" - executor = TESExecutor( - image="python:3.8-slim", - command=["python", "script.py"], - workdir="/app", - stdout="/logs/stdout.log", - stderr="/logs/stderr.log", - stdin="/input/input.txt", - env={"ENV_VAR": "value"}, - ) - assert executor.image == "python:3.8-slim" - assert executor.command == ["python", "script.py"] - assert executor.workdir == "/app" - assert executor.stdout == "/logs/stdout.log" - assert executor.stderr == "/logs/stderr.log" - assert executor.stdin == "/input/input.txt" - assert executor.env == {"ENV_VAR": "value"} - - -def test_tes_executor_with_relative_stdin(): - """Test TESExecutor model with a relative stdin path (should raise ValidationError).""" - with pytest.raises(ValidationError) as exc_info: - TESExecutor( - image="python:3.8-slim", - command=["python", "script.py"], - stdin="input.txt", - ) - assert "The 'stdin' attribute must contain an absolute path." in str(exc_info.value) - -def test_tes_data_with_inputs(): - """Test TESData model with inputs only.""" - tes_data = TESData( - id="task-123", - executors=[ - TESExecutor( - image="python:3.8-slim", - command=["python", "script.py"] +valid_datetime_strings = [ + "2020-10-02T16:00:00.000Z", + "2024-10-15T18:14:34+00:00", + "2024-10-15T18:14:34.948996+00:00", + "2024-10-15T19:01:06.872464+00:00", +] + +invalid_datetime_strings = [ + "2020-10-02 16:00:00", # Missing 'T' separator + "2020-10-02T16:00:00", # Missing timezone or fractional seconds + "20201002T160000Z", # Missing separators + "2020-10-02T16:00:00.000+0200", # Invalid timezone format + "2020-10-02T16:00:00.000 GMT", # Invalid timezone format + "02-10-2020T16:00:00.000Z", # Incorrect date order +] + +valid_paths = [ + "/", + "/random_path", + r"C:\Users\user\Document.pdf", + r"D:\Projects\my_website\index.html", + r"\\server\share", + "s3://my-bucket/data/file.txt", +] + +invalid_paths = [ + "str", + "./random_path", + "..some_path", +] + +test_url = "https://raw.githubusercontent.com/elixir-cloud-aai/CrateGen/refs/heads/main/README.md" + + +class TestTESExecutorLog: + """Test suite for the TESExecutor model validators.""" + + def test_validate_datetime_valid(self): + """Test that datetime validator accepts correct datetime strings.""" + for valid_datetime in valid_datetime_strings: + # Create a TESExecutorLog object (we're just interested in the validator) + log_entry = TESExecutorLog( + start_time=valid_datetime, end_time=valid_datetime, exit_code=0 ) - ], - inputs=[ - TESInput( - name="Test Input", - url="https://example.com/input.txt", - path="/data/input/input.txt" + assert bool(log_entry.start_time) + assert bool(log_entry.end_time) + + def test_validate_datetime_invalid(self): + """Test that datetime validator rejects correct datetime strings.""" + for invalid_datetime in invalid_datetime_strings: + with pytest.raises(ValueError) as exc_info: + TESExecutorLog( + start_time=invalid_datetime, + end_time="2020-10-02T16:00:00.000Z", + exit_code=0, + ) + + assert "The 'start_time' property must be in the rfc3339 format" in str( + exc_info.value ) - ] - ) - assert tes_data.inputs is not None - assert len(tes_data.inputs) == 1 - - -def test_tes_data_with_outputs(): - """Test TESData model with outputs only.""" - tes_data = TESData( - id="task-123", - executors=[ - TESExecutor( - image="python:3.8-slim", - command=["python", "script.py"] + + with pytest.raises(ValueError) as exc_info: + TESExecutorLog( + start_time="2020-10-02T16:00:00.000Z", + end_time=invalid_datetime, + exit_code=0, + ) + + # check the error message + assert "The 'end_time' property must be in the rfc3339 format" in str( + exc_info.value ) - ], - outputs=[ - TESOutput( - name="Test Output", - url="https://example.com/output.txt", - path="/data/output/output.txt" + + +class TestTESExecutor: + """Test suite for the TESExecutorLog model validators.""" + + def test_validate_stdin_stdout_valid(self): + """Test that validator accepts valid paths""" + for path in valid_paths: + executor = TESExecutor( + image="image", command=["commands"], stdin=path, stdout=path ) - ] - ) - assert tes_data.outputs is not None - assert len(tes_data.outputs) == 1 - -def test_tes_data_with_resources(): - """Test TESData model with resources.""" - tes_data = TESData( - id="task-123", - executors=[ - TESExecutor( - image="python:3.8-slim", - command=["python", "script.py"] + + assert bool(executor) + + def test_validate_stdin_stdout_invalid(self): + """Test that validator rejects invalid paths""" + for path in invalid_paths: + with pytest.raises(ValueError) as exc_info: + TESExecutor(image="image", command=["commands"], stdin=path, stdout="/") + + assert "The 'stdin' property must be an absolute path" in str( + exc_info.value ) - ], - resources=TESResources( - cpu_cores=EXPECTED_CPU_CORES, - ram_gb=EXPECTED_RAM_GB - ) - ) - assert tes_data.resources.cpu_cores == EXPECTED_CPU_CORES - assert tes_data.resources.ram_gb == EXPECTED_RAM_GB - -def test_tes_data_missing_required_fields(): - """Test TESData model missing required fields (should raise ValidationError).""" - with pytest.raises(ValidationError) as exc_info: - TESData() - errors = exc_info.value.errors() - required_fields = [error['loc'][0] for error in errors if error['type'] == 'value_error.missing'] - assert 'executors' in required_fields - -def test_tes_output_with_wildcards_missing_path_prefix(): - """Test TESOutput with wildcards in 'path' without 'path_prefix'.""" - with pytest.raises(ValidationError) as exc_info: - TESOutput( - name="Wildcard Output", - url="https://example.com/output/*", - path="/data/output/*", + + with pytest.raises(ValueError) as exc_info: + TESExecutor(image="image", command=["commands"], stdin="/", stdout=path) + + assert "The 'stdout' property must be an absolute path" in str( + exc_info.value + ) + + +class TestTESTInput: + """Test suite for the TESData model validators.""" + + def test_validate_path_valid(self): + """Test path accepts absolute paths.""" + for valid_path in valid_paths: + input_data = TESInput( + url=test_url, + path=valid_path, + ) + + assert bool(input_data.path) + + def test_validate_path_invalid(self): + """Test path rejects non-absolute paths.""" + for invalid_path in invalid_paths: + with pytest.raises(ValueError) as exc_info: + TESInput( + url=test_url, + path=invalid_path, + ) + + assert "The 'path' property must be an absolute path" in str(exc_info.value) + + def test_validate_no_content_or_url(self): + """An error should be thrown if both content and url are not set""" + with pytest.raises(ValueError) as exc_info: + TESInput(path="/") + + assert "Either the 'url' or 'content' properties must be set" in str( + exc_info.value ) - assert "When 'path' contains wildcards, 'path_prefix' is required." in str(exc_info.value) - -def test_tes_output_with_wildcards_and_path_prefix(): - """Test TESOutput with wildcards in 'path' and provided 'path_prefix'.""" - output_data = TESOutput( - name="Wildcard Output", - url="https://example.com/output/*", - path="/data/output/*", - path_prefix="/data/output", - ) - assert output_data.path_prefix == "/data/output" - -def test_tes_executor_with_ignore_error(): - """Test TESExecutor model with 'ignore_error' field set to True.""" - executor = TESExecutor( - image="python:3.8-slim", - command=["python", "script.py"], - ignore_error=True - ) - assert executor.ignore_error is True + def test_validate_content_or_url(self): + """No error if either content or url are set""" + tes_input = TESInput(path="/", url=test_url) + + assert bool(tes_input.url) + assert not bool(tes_input.content) + + tes_input = TESInput(path="/", content="content") + + assert not bool(tes_input.url) + assert bool(tes_input.content) + + def test_validate_not_content_and_url(self): + """If both content and url are set, url should be automatically unset""" + tes_input = TESInput(path="/", url=test_url, content="content") + + assert not bool(tes_input.url) + assert bool(tes_input.content) + + +class TestTESOutput: + """Test suite for TESOutput model validators.""" + + def test_path_valid(self): + """Test that validator accepts valid paths""" + for valid_path in valid_paths: + tes_output = TESOutput(url=test_url, path=valid_path) + + assert bool(tes_output) + + def test_path_invalid(self): + """Test that validator rejects invalid paths""" + for invalid_path in invalid_paths: + with pytest.raises(ValueError) as exc_info: + TESOutput(url=test_url, path=invalid_path) + + assert "The 'path' property must be an absolute path" in str(exc_info.value) + + +class TestTESTaskLog: + """Test suite for TESTaskLog model validators.""" + + tes_output_file_log = TESOutputFileLog(url=test_url, path="/", size_bytes="10gb") + tes_executor_log = TESExecutorLog(exit_code=0) + + def test_validate_datetime_valid(self): + """Test that the validator accepts valid paths""" + for time in valid_datetime_strings: + tes_task_log = TESTaskLog( + outputs=[self.tes_output_file_log], + logs=[self.tes_executor_log], + start_time=time, + end_time=time, + ) + + assert bool(tes_task_log.start_time) + + def test_validate_datetime_invalid(self): + """Test that the validator rejects valid paths""" + for time in invalid_datetime_strings: + with pytest.raises(ValueError) as exc_info: + TESTaskLog( + outputs=[self.tes_output_file_log], + logs=[self.tes_executor_log], + start_time="2020-10-02T16:00:00.000Z", + end_time=time, + ) + + assert "The 'end_time' property must be in the rfc3339 format" in str( + exc_info.value + ) + + with pytest.raises(ValueError) as exc_info: + TESTaskLog( + outputs=[self.tes_output_file_log], + logs=[self.tes_executor_log], + start_time=time, + end_time="2020-10-02T16:00:00.000Z", + ) + + assert "The 'start_time' property must be in the rfc3339 format" in str( + exc_info.value + ) + + +class TestTESData: + """Test suite for the TESData model.""" + + executor = TESExecutor(image="image", command=["commands"], stdin="/", stdout="/") + + def test_validate_datetime_valid(self): + """Test that datetime validator accepts correct datetime strings.""" + for valid_datetime in valid_datetime_strings: + data = TESData( + id="id", creation_time=valid_datetime, executors=[self.executor] + ) + assert bool(data.creation_time) + + def test_validate_datettime_invalid(self): + """Test that datetime validator rejects incorrect datetime strings.""" + for invalid_datetime in invalid_datetime_strings: + with pytest.raises(ValueError) as exc_info: + TESData( + id="id", creation_time=invalid_datetime, executors=[self.executor] + ) + + assert "The 'creation_time' property must be in the rfc3339 format" in str( + exc_info.value + )