From 0e80889f37efa3b6ca71eb8aee6491506a4c99f7 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Wed, 7 Aug 2024 22:01:10 +0200 Subject: [PATCH 1/5] test layout --- src/ez_azml/cloud_runs/pipelines/__init__.py | 4 +- src/ez_azml/cloud_runs/pipelines/pipeline.py | 6 +-- tests/cloud_runs/pipelines/test_pipeline.py | 16 +++++++ tests/conftest.py | 48 ++++++++++++++++++++ 4 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 tests/cloud_runs/pipelines/test_pipeline.py create mode 100644 tests/conftest.py diff --git a/src/ez_azml/cloud_runs/pipelines/__init__.py b/src/ez_azml/cloud_runs/pipelines/__init__.py index d2381c3..2c6c5bb 100644 --- a/src/ez_azml/cloud_runs/pipelines/__init__.py +++ b/src/ez_azml/cloud_runs/pipelines/__init__.py @@ -1,3 +1,3 @@ -from .pipeline import Pipeline +from .pipeline import Pipeline, PipelineCommand -__all__ = ["Pipeline"] +__all__ = ["Pipeline", "PipelineCommand"] diff --git a/src/ez_azml/cloud_runs/pipelines/pipeline.py b/src/ez_azml/cloud_runs/pipelines/pipeline.py index 0c270fb..b5d76b8 100644 --- a/src/ez_azml/cloud_runs/pipelines/pipeline.py +++ b/src/ez_azml/cloud_runs/pipelines/pipeline.py @@ -11,7 +11,7 @@ @dataclass -class Command: +class PipelineCommand: """Class representing a mldesigner Azureml step (mldesigner.command_component). Args: @@ -41,7 +41,7 @@ class Pipeline(CloudRun): def __init__( self, experiment_name: str, - commands: list[Command], + commands: list[PipelineCommand], pipeline: Callable, dec_kwargs: Optional[dict[str, Any]] = None, **kwargs, @@ -52,7 +52,7 @@ def __init__( self.dec_kwargs = dec_kwargs or {} self.pipeline = pipeline - def _register_components(self, commands: list[Command]) -> list[Callable]: + def _register_components(self, commands: list[PipelineCommand]) -> list[Callable]: decorated = [] kwargs_pattern = r"(\w+)\s*=\s*(['\"]?)(\w+)\2" for command in commands: diff --git a/tests/cloud_runs/pipelines/test_pipeline.py b/tests/cloud_runs/pipelines/test_pipeline.py new file mode 100644 index 0000000..1e4b9ff --- /dev/null +++ b/tests/cloud_runs/pipelines/test_pipeline.py @@ -0,0 +1,16 @@ +from ez_azml.cloud_runs.pipelines import Pipeline + + +def test_pipeline_register_components(pipeline: Pipeline): + """Tests that the components are properly decorated.""" + pipeline.commands = pipeline._register_components(pipeline.commands) + + +def test_pipeline_setup_dec_kwargs(pipeline: Pipeline): + """Tests that the pipeline decorator kwargs are properly postprocessed.""" + pipeline.dec_kwargs = pipeline._setup_dec_kwargs(pipeline.dec_kwargs) + + +def test_build_pipeline(pipeline: Pipeline): + """Tests that the pipeline is properly decorated.""" + pipeline.pipeline = pipeline._build_pipeline(pipeline.pipeline, pipeline.dec_kwargs) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a401450 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,48 @@ +from typing import Callable + +import mldesigner as mld +import pytest +from azure.ai.ml import Input, Output +from ez_azml.cloud_runs.pipelines import Pipeline, PipelineCommand + + +@pytest.fixture() +def command_fn() -> Callable: + """Function used in mldesigner command component.""" + + def _command_fn( + command_input: mld.Input(type="uri_folder"), # type: ignore + command_output: mld.Output(type="uri_folder"), # type: ignore + ): + print("this is a test_fn") + + return _command_fn + + +@pytest.fixture() +def pipeline_fn(command_fn) -> Callable: + """Function used in azure.ai.ml pipelines.""" + + def _pipeline_fn( + pipeline_input: Input, + pipeline_output: Output, + ): + command_fn(pipeline_input) + + return _pipeline_fn + + +@pytest.fixture() +def pipeline_command(command_fn: Callable): + """PipelineCommand fixture.""" + return PipelineCommand(function=command_fn) + + +@pytest.fixture() +def pipeline(pipeline_fn: Callable, pipeline_command: PipelineCommand): + """Pipeline cloud run fixture.""" + return Pipeline( + experiment_name="fixture", + commands=[pipeline_command], + pipeline=pipeline_fn, + ) From 4acfefea7418c0a9e5a86d291d6f8395c0853188 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Thu, 8 Aug 2024 10:56:16 +0200 Subject: [PATCH 2/5] initial tests --- pyproject.toml | 2 ++ src/ez_azml/cloud_runs/pipelines/pipeline.py | 26 +++++++++----- tests/cloud_runs/pipelines/test_pipeline.py | 37 ++++++++++++++++++-- tests/conftest.py | 8 ++--- 4 files changed, 59 insertions(+), 14 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 01bc1b4..ce49baa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,6 +87,8 @@ ignore = [ "UP007", "UP038" ] +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] [tool.ruff.lint.mccabe] max-complexity = 10 [tool.ruff.lint.pydocstyle] diff --git a/src/ez_azml/cloud_runs/pipelines/pipeline.py b/src/ez_azml/cloud_runs/pipelines/pipeline.py index b5d76b8..b412376 100644 --- a/src/ez_azml/cloud_runs/pipelines/pipeline.py +++ b/src/ez_azml/cloud_runs/pipelines/pipeline.py @@ -27,6 +27,11 @@ class PipelineCommand: def __post_init__(self): self.component_kwargs["environment"] = self.environment + @property + def name(self): + """Command's name.""" + return self.function.__name__ + class Pipeline(CloudRun): """Cloud run that uses mldesigner pipelines. @@ -52,8 +57,9 @@ def __init__( self.dec_kwargs = dec_kwargs or {} self.pipeline = pipeline - def _register_components(self, commands: list[PipelineCommand]) -> list[Callable]: - decorated = [] + def _register_components( + self, commands: list[PipelineCommand] + ) -> list[PipelineCommand]: kwargs_pattern = r"(\w+)\s*=\s*(['\"]?)(\w+)\2" for command in commands: fn = command.function @@ -68,9 +74,8 @@ def _register_components(self, commands: list[PipelineCommand]) -> list[Callable elif "Output(" in hint: hint = mld.Output(**potential_kwargs) fn.__annotations__[parameter] = hint - decorated_command = mld.command_component(**command.component_kwargs)(fn) - decorated.append(decorated_command) - return decorated + command.function = mld.command_component(**command.component_kwargs)(fn) + return commands def _build_pipeline(self, pipeline: Callable, dec_kwargs: Optional[dict[str, Any]]): # Inject the decorated functions @@ -80,9 +85,14 @@ def _build_pipeline(self, pipeline: Callable, dec_kwargs: Optional[dict[str, Any return pipeline_dec(**dec_kwargs)(pipeline) def _setup_dec_kwargs(self, dec_kwargs: dict[str, Any]): - dec_kwargs["default_compute"] = dec_kwargs.get( - "default_compute", self.compute.name - ) + if self.compute: + dec_kwargs["default_compute"] = dec_kwargs.get( + "default_compute", self.compute.name + ) + if self.environment: + dec_kwargs["default_compute"] = dec_kwargs.get( + "default_compute", self.compute.name + ) return dec_kwargs @override diff --git a/tests/cloud_runs/pipelines/test_pipeline.py b/tests/cloud_runs/pipelines/test_pipeline.py index 1e4b9ff..026e835 100644 --- a/tests/cloud_runs/pipelines/test_pipeline.py +++ b/tests/cloud_runs/pipelines/test_pipeline.py @@ -1,9 +1,33 @@ +import inspect +from typing import Callable +from unittest import mock + from ez_azml.cloud_runs.pipelines import Pipeline +def assert_function_wrapped(function: Callable, wrapped_function: Callable) -> None: + """Asserts that a function is properly wrapped. + + Args: + wrapped_function: The function that is potentially wrapped. + function: The original function to compare against after unwrapping. + + Raises: + AssertionError: If the function is already wrapped or if unwrapping the wrapped + function does not yield the original function. + """ + assert not hasattr(function, "__wrapped__"), "The function should not be wrapped." + assert ( + inspect.unwrap(wrapped_function) is function + ), "Unwrapping the wrapped function did not yield the original function." + + def test_pipeline_register_components(pipeline: Pipeline): """Tests that the components are properly decorated.""" - pipeline.commands = pipeline._register_components(pipeline.commands) + raw_functions = [c.function for c in pipeline.commands] + registered_commands = pipeline._register_components(pipeline.commands) + for raw_function, registered in zip(raw_functions, registered_commands): + assert_function_wrapped(raw_function, registered.function) def test_pipeline_setup_dec_kwargs(pipeline: Pipeline): @@ -13,4 +37,13 @@ def test_pipeline_setup_dec_kwargs(pipeline: Pipeline): def test_build_pipeline(pipeline: Pipeline): """Tests that the pipeline is properly decorated.""" - pipeline.pipeline = pipeline._build_pipeline(pipeline.pipeline, pipeline.dec_kwargs) + + def f(): + pass + + with mock.patch.object(pipeline, "commands", return_value=f) as mocked: + mocked.__name__ = "command_fn" + + raw_pipeline = pipeline.pipeline + built_pipeline = pipeline._build_pipeline(raw_pipeline, pipeline.dec_kwargs) + assert_function_wrapped(raw_pipeline, built_pipeline) diff --git a/tests/conftest.py b/tests/conftest.py index a401450..8ff4003 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,26 +10,26 @@ def command_fn() -> Callable: """Function used in mldesigner command component.""" - def _command_fn( + def command__fn( command_input: mld.Input(type="uri_folder"), # type: ignore command_output: mld.Output(type="uri_folder"), # type: ignore ): print("this is a test_fn") - return _command_fn + return command__fn @pytest.fixture() def pipeline_fn(command_fn) -> Callable: """Function used in azure.ai.ml pipelines.""" - def _pipeline_fn( + def pipeline__fn( pipeline_input: Input, pipeline_output: Output, ): command_fn(pipeline_input) - return _pipeline_fn + return pipeline__fn @pytest.fixture() From e9106e1af5450e1195a3fb2349e6d91687b5f178 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Thu, 8 Aug 2024 11:11:42 +0200 Subject: [PATCH 3/5] test ga --- .github/workflows/tests.yaml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/tests.yaml diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..8394926 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,27 @@ +name: Build and Deploy +on: + workflow_dispatch: + pull_request: + types: [opened, reopened] +permissions: + contents: write +jobs: + Testing: + runs-on: ${{ matrix.os }} + strategy: + matrix: + python-version: [3.9, '3.10', '3.11'] + os: [ubuntu-latest, macOS-latest, windows-latest] + + steps: + - uses: actions/checkout@v3 + - name: Set up PDM + uses: pdm-project/setup-pdm@v4.1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + pdm sync -d -G testing + - name: Run Tests + run: | + pdm run -v pytest tests From 57b57ffa659ccacec4bd5b585863d52a61b4bc7a Mon Sep 17 00:00:00 2001 From: Alejandro Date: Thu, 8 Aug 2024 11:53:06 +0200 Subject: [PATCH 4/5] setuptools needed --- .github/workflows/tests.yaml | 1 - pdm.lock | 13 ++++++++++++- pyproject.toml | 1 + requirements.txt | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 8394926..41ce172 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -2,7 +2,6 @@ name: Build and Deploy on: workflow_dispatch: pull_request: - types: [opened, reopened] permissions: contents: write jobs: diff --git a/pdm.lock b/pdm.lock index 2bc01eb..2736394 100644 --- a/pdm.lock +++ b/pdm.lock @@ -5,7 +5,7 @@ groups = ["default", "dev"] strategy = ["inherit_metadata"] lock_version = "4.5.0" -content_hash = "sha256:b20c1263a5a2fc85432442707f23b43758dbf4ae1236c6712fe4f2a9c43251e1" +content_hash = "sha256:e7c33c63b50a398da21d56c7916b247678e75d8a209b9eb36e47407a20e72840" [[metadata.targets]] requires_python = ">=3.9" @@ -1174,6 +1174,17 @@ files = [ {file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"}, ] +[[package]] +name = "setuptools" +version = "72.1.0" +requires_python = ">=3.8" +summary = "Easily download, build, install, upgrade, and uninstall Python packages" +groups = ["default"] +files = [ + {file = "setuptools-72.1.0-py3-none-any.whl", hash = "sha256:5a03e1860cf56bb6ef48ce186b0e557fdba433237481a9a625176c2831be15d1"}, + {file = "setuptools-72.1.0.tar.gz", hash = "sha256:8d243eff56d095e5817f796ede6ae32941278f542e0f941867cc05ae52b162ec"}, +] + [[package]] name = "six" version = "1.16.0" diff --git a/pyproject.toml b/pyproject.toml index ce49baa..e9f2756 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,7 @@ dependencies = [ "azure-identity>=1.17.1", "loguru>=0.7.2", "mldesigner>=0.1.0b18", + "setuptools>=72.1.0", ] requires-python = ">=3.9" readme = "README.md" diff --git a/requirements.txt b/requirements.txt index be5a0fc..4aeb9aa 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,6 +60,7 @@ requests==2.32.3 requests-oauthlib==2.0.0 rpds-py==0.19.1 rsa==4.9 +setuptools==72.1.0 six==1.16.0 strictyaml==1.7.3 tomli==2.0.1 From f16445f843227763dbb66ee163442e75d2399efb Mon Sep 17 00:00:00 2001 From: Alejandro Date: Thu, 8 Aug 2024 12:12:37 +0200 Subject: [PATCH 5/5] ez-azml as conda dependency --- .github/workflows/{tests.yaml => tests.yml} | 0 configs/environments/conda.yaml | 1 + pyproject.toml | 2 +- 3 files changed, 2 insertions(+), 1 deletion(-) rename .github/workflows/{tests.yaml => tests.yml} (100%) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yml similarity index 100% rename from .github/workflows/tests.yaml rename to .github/workflows/tests.yml diff --git a/configs/environments/conda.yaml b/configs/environments/conda.yaml index 24eaa1d..a9de33d 100644 --- a/configs/environments/conda.yaml +++ b/configs/environments/conda.yaml @@ -8,3 +8,4 @@ dependencies: - mldesigner==0.1.0b17 - azure-ai-ml==1.18.0 - azureml-mlflow==1.56.0 + - ez-azml==0.1.0 diff --git a/pyproject.toml b/pyproject.toml index e9f2756..3f7dfb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [project] -name = "ez_azml" +name = "ez-azml" dynamic = ["version"] description = "Simplified yaml/jsonargparse driven framework for AzureML interactions" authors = [