Repository: pluto-lang/pluto (language: python)

Issue:
Version Packages
This PR was opened by the [Changesets release](https://github.com/changesets/action) GitHub action. When you're ready to do a release, you can merge this and the packages will be published to npm automatically. If you're not ready to do a release yet, that's fine, whenever you add more changesets to main, this PR will be updated.


# Releases
## @plutolang/cli@0.2.10

### Patch Changes

-   62a0009: feat: instantiate resource infrastructure classes asynchronously within the base class of each resource
-   Updated dependencies [62a0009]
    -   @plutolang/simulator-adapter@0.2.9
    -   @plutolang/static-generator@0.2.8
    -   @plutolang/pulumi-adapter@0.2.9
    -   @plutolang/base@0.2.8
    -   @plutolang/static-deducer@0.2.8
    -   @plutolang/graphviz-generator@0.2.8

## @plutolang/pulumi-adapter@0.2.9

### Patch Changes

-   62a0009: feat: instantiate resource infrastructure classes asynchronously within the base class of each resource
-   Updated dependencies [62a0009]
    -   @plutolang/base@0.2.8

## @plutolang/simulator-adapter@0.2.9

### Patch Changes

-   62a0009: feat: instantiate resource infrastructure classes asynchronously within the base class of each resource
-   Updated dependencies [62a0009]
    -   @plutolang/pluto-infra@0.2.9
    -   @plutolang/pluto@0.2.8
    -   @plutolang/base@0.2.8

## @plutolang/static-deducer@0.2.8

### Patch Changes

-   Updated dependencies [62a0009]
    -   @plutolang/base@0.2.8

## @plutolang/graphviz-generator@0.2.8

### Patch Changes

-   Updated dependencies [62a0009]
    -   @plutolang/base@0.2.8

## @plutolang/static-generator@0.2.8

### Patch Changes

-   62a0009: feat: instantiate resource infrastructure classes asynchronously within the base class of each resource
-   Updated dependencies [62a0009]
    -   @plutolang/base@0.2.8

## @plutolang/base@0.2.8

### Patch Changes

-   62a0009: feat: instantiate resource infrastructure classes asynchronously within the base class of each resource

## @plutolang/pluto@0.2.8

### Patch Changes

-   62a0009: feat: instantiate resource infrastructure classes asynchronously within the base class of each resource
-   Updated dependencies [62a0009]
    -   @plutolang/base@0.2.8

## @plutolang/pluto-infra@0.2.9

### Patch Changes

-   62a0009: feat: instantiate resource infrastructure classes asynchronously within the base class of each resource
-   Updated dependencies [62a0009]
    -   @plutolang/pluto@0.2.8
    -   @plutolang/base@0.2.8



Discussion / hints:

Fix patch:
diff --git a/.changeset/config.json b/.changeset/config.json
index 57ee1cc5..fc9f6e22 100644
--- a/.changeset/config.json
+++ b/.changeset/config.json
@@ -8,6 +8,7 @@
   "baseBranch": "main",
   "updateInternalDependencies": "patch",
   "ignore": [
+    "base-py",
     "tester",
     "tester-py",
     "app-with-prop-access",
diff --git a/packages/base-py/CHANGELOG.md b/packages/base-py/CHANGELOG.md
index 920b4814..ede4af4a 100644
--- a/packages/base-py/CHANGELOG.md
+++ b/packages/base-py/CHANGELOG.md
@@ -1,5 +1,13 @@
 # pluto-base
 
+## 0.0.6
+
+feat: add no-op infrastructure API to client SDK
+
+To prevent runtime errors, a no-op (no operation) implementation of the infrastructure API has been introduced in the client SDK. This ensures that when pytest is run locally, calls to the infrastructure API do not result in errors.
+
+Future plans may involve bundling the entire user code, not just the business logic, which further necessitates a no-op infrastructure API in the SDK to avoid runtime exceptions.
+
 ## 0.0.5
 
 refactor: replace build_client with constructor usage
diff --git a/packages/base-py/package.json b/packages/base-py/package.json
new file mode 100644
index 00000000..b69c0092
--- /dev/null
+++ b/packages/base-py/package.json
@@ -0,0 +1,8 @@
+{
+    "name": "base-py",
+    "private": true,
+    "version": "0.0.0",
+    "scripts": {
+        "test": "PYTHONPATH=$(pwd) pytest tests"
+    }
+}
\ No newline at end of file
diff --git a/packages/base-py/pluto_base/resource.py b/packages/base-py/pluto_base/resource.py
index e642d137..a242fd4e 100644
--- a/packages/base-py/pluto_base/resource.py
+++ b/packages/base-py/pluto_base/resource.py
@@ -1,5 +1,7 @@
 from abc import ABC
-from typing import Protocol
+from typing import Callable, List, Type
+
+_empty_lambda: Callable[..., None] = lambda *args, **kwargs: None
 
 
 class IResourceClientApi(ABC):
@@ -14,30 +16,103 @@ class IResourceCapturedProps(ABC):
     pass
 
 
-class _IResourceClient(Protocol, IResourceClientApi, IResourceCapturedProps):  # type: ignore
-    pass
-
-
 class IResource(ABC):
     fqn: str
     """
     Fully qualified name of the resource type. It should be same between the client and the infra
     sdk.
     """
-    _client: _IResourceClient
+    _client: IResourceClientApi | IResourceCapturedProps | None
+    """
+    The client implemention of the resource for the specific cloud provider. 
+    
+    The type of this attribute should be (IResourceClientApi & IResourceCapturedProps), but mypy
+    doesn't support this.
+    """
+    _infra_iface: Type[IResourceInfraApi] | None
     """
-    The client implemention of the resource for the specific cloud provider.
+    The interface that contains the infrastructure API of the resource. This is used to check if an
+    infrastructure API is called during runtime. If it is called, the call will be skipped.
     """
 
+    def __get_infra_apis(self) -> List[str]:
+        """
+        Get a list of infrastructure APIs defined in the resource's infrastructure interface.
+
+        Returns:
+            A list of strings representing the names of the user defined infrastructure APIs.
+
+        Raises:
+            ValueError: If multiple classes that inherit from IResourceInfraApi are found.
+        """
+
+        infra_iface: Type[IResourceInfraApi] | None = None
+
+        # Try to get the _infra_iface attribute from self, if user has set it, use it.
+        try:
+            infra_iface = self._infra_iface
+        except:
+            pass
+
+        if infra_iface is None:
+            # If the _infra_iface attribute doesn't exist, try to find the class that is a subclass
+            # of IResourceInfraApi and not a subclass of IResourceClientApi or
+            # IResourceCapturedProps.
+
+            # First, get all the classes that are a subclass of IResourceInfraApi.
+            infra_iface_clses: List[Type[IResourceInfraApi]] = [
+                klass
+                for klass in self.__class__.mro()
+                if issubclass(klass, IResourceInfraApi)
+            ]
+
+            # Second, find the class that is a subclass of IResourceInfraApi and not a subclass of
+            # IResourceClientApi or IResourceCapturedProps
+            for klass in infra_iface_clses:
+                if klass == IResourceInfraApi:
+                    # Skip the IResourceInfraApi class itself.
+                    continue
+
+                if not issubclass(klass, IResourceClientApi) and not issubclass(
+                    klass, IResourceCapturedProps
+                ):
+                    if infra_iface is not None:
+                        # Found multiple classes that inherit from IResourceInfraApi.
+                        raise ValueError(
+                            "Multiple base classes that inherit from IResourceInfraApi are found."
+                        )
+                    infra_iface = klass
+
+            # Set the _infra_iface attribute to the found class.
+            if infra_iface is not None:
+                super().__setattr__("_infra_iface", infra_iface)
+
+        # If the _infra_iface attribute is still None, return an empty list.
+        if infra_iface is None:
+            return []
+
+        # Get all the user defined infrastructure APIs.
+        # RULE: The infrastructure APIs should not start with "__".
+        infra_apis = [
+            func
+            for func in dir(infra_iface)
+            if callable(getattr(infra_iface, func)) and not func.startswith("__")
+        ]
+        return infra_apis
+
     def __getattribute__(self, name: str):
-        if name == "fqn":
+        if name == "fqn" or name.startswith("_"):
             return super().__getattribute__(name)
 
+        # Check if the attribute is an infrastructure API, if it is, return an empty lambda.
+        infra_apis = self.__get_infra_apis()
+        if name in infra_apis:
+            return _empty_lambda
+
         # Try to get the attribute from the client, if it doesn't exist, return the attribute of
         # self. This is to make sure that the client is the first priority.
         try:
-            client = super().__getattribute__("_client")
-            return getattr(client, name)
+            return getattr(self._client, name)
         except:
             # If the _client doesn't exist, or the attribute doesn't exist in the client, return the
             # attribute of self.
diff --git a/packages/base-py/pyproject.toml b/packages/base-py/pyproject.toml
index f3b27957..78058b30 100644
--- a/packages/base-py/pyproject.toml
+++ b/packages/base-py/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
 
 [tool.poetry]
 name = "pluto-base"
-version = "0.0.5"
+version = "0.0.6"
 description = "The Base Library for Pluto Programming Language."
 authors = ["Jade Zheng <zheng.shoujian@outlook.com>"]
 license = "Apache-2.0"
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ea0b4f59..df3016bb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -686,6 +686,8 @@ importers:
         specifier: ^0.34.6
         version: 0.34.6(terser@5.30.0)
 
+  packages/base-py: {}
+
   packages/pluto:
     dependencies:
       '@alicloud/credentials':
diff --git a/requirements.txt b/requirements.txt
index 1e593cec..0bb29535 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
 boto3==1.34.54
 pydantic==2.6.3
+pytest==8.3.2
 pluto_client
 pluto_base
\ No newline at end of file


Test patch:
diff --git a/packages/base-py/tests/test_resource.py b/packages/base-py/tests/test_resource.py
new file mode 100644
index 00000000..dbfbd664
--- /dev/null
+++ b/packages/base-py/tests/test_resource.py
@@ -0,0 +1,77 @@
+from typing import Any, Callable
+
+import pytest
+from pluto_base import resource
+
+
+class ResourceInfraApi(resource.IResourceInfraApi):
+    def infra_api_1(self) -> Any:
+        pass
+
+    def infra_api_2(self, _a: Any) -> int:
+        raise NotImplementedError
+
+
+class ResourceClientApi(resource.IResourceClientApi):
+    def client_api(self) -> Any:
+        pass
+
+
+class ResourceCapturedProps(resource.IResourceCapturedProps):
+    pass
+
+
+class ResourceClient(ResourceClientApi, ResourceCapturedProps):
+    pass
+
+
+class ResourceInfra(ResourceInfraApi, ResourceCapturedProps):
+    pass
+
+
+class Resource(resource.IResource, ResourceClient, ResourceInfra):
+    def client_api(self) -> Any:
+        return "client_api"
+
+
+def test_exec_infra_api_without_args():
+    """
+    Test to execute an infrastructure API without arguments.
+    """
+    r = Resource()
+
+    result = r.infra_api_1()
+    assert isinstance(r.infra_api_1, Callable)
+    assert result is None
+
+
+def test_exec_infra_api_with_args():
+    """
+    Test to execute an infrastructure API with arguments.
+    """
+    r = Resource()
+
+    result = r.infra_api_2(1)
+    assert isinstance(r.infra_api_2, Callable)
+    assert result is None
+
+
+def test_exec_not_exist_infra_api():
+    """
+    Test to execute a non-existing infrastructure API. It should raise an exception.
+    """
+    r = Resource()
+
+    with pytest.raises(Exception):
+        r.infra_api()
+
+
+def test_exec_client_api():
+    """
+    Test to execute a client API.
+    """
+    r = Resource()
+
+    result = r.client_api()
+    assert isinstance(r.client_api, Callable)
+    assert result == "client_api"