Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add json_dumps to python sdk #11607

Merged
merged 1 commit into from Dec 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
@@ -0,0 +1,4 @@
changes:
- type: feat
scope: sdk/python
description: Add json_dumps to python sdk.
128 changes: 128 additions & 0 deletions sdk/python/lib/pulumi/output.py
Expand Up @@ -13,9 +13,12 @@
# limitations under the License.
import asyncio
import contextlib
import json
from functools import reduce
from inspect import isawaitable
from typing import (
Tuple,
Type,
TypeVar,
Generic,
Set,
Expand All @@ -35,6 +38,7 @@
from . import _types
from . import runtime
from .runtime import rpc
from .runtime.sync_await import _sync_await

if TYPE_CHECKING:
from .resource import Resource
Expand Down Expand Up @@ -536,6 +540,130 @@ def format(
)
return Output.from_input(format_string).apply(lambda str: str.format())

@staticmethod
def json_dumps(
obj: Input[Any],
*,
skipkeys: bool = False,
ensure_ascii: bool = True,
check_circular: bool = True,
allow_nan: bool = True,
cls: Optional[Type[json.JSONEncoder]] = None,
indent: Optional[Union[int, str]] = None,
separators: Optional[Tuple[str, str]] = None,
default: Optional[Callable[[Any], Any]] = None,
sort_keys: bool = False,
**kw: Any
) -> "Output[str]":
"""
Uses json.dumps to serialize the given Input[object] value into a JSON string.

The arguments have the same meaning as in `json.dumps` except obj is an Input.
"""

if cls is None:
cls = json.JSONEncoder

output = Output.from_input(obj)
result_resources: asyncio.Future[Set["Resource"]] = asyncio.Future()
result_is_known: asyncio.Future[bool] = asyncio.Future()
result_is_secret: asyncio.Future[bool] = asyncio.Future()

async def run() -> str:
resources: Set["Resource"] = set()
try:
seen_unknown = False
seen_secret = False
seen_resources = set()

class OutputEncoder(cls): # type: ignore
def default(self, o):
if isinstance(o, Output):
nonlocal seen_unknown
nonlocal seen_secret
nonlocal seen_resources

# We need to synchronously wait for o to complete
async def wait_output() -> Tuple[object, bool, bool, set]:
return (
await o._future,
await o._is_known,
await o._is_secret,
await o._resources,
)

(result, known, secret, resources) = _sync_await(
asyncio.ensure_future(wait_output())
)
# Update the secret flag and set of seen resources
seen_secret = seen_secret or secret
seen_resources.update(resources)
if known:
return result
# The value wasn't known set the local seenUnknown variable and just return None
# so the serialization doesn't raise an exception at this point
seen_unknown = True
return None

return super().default(o)

# Await the output's details.
resources = await output._resources
is_known = await output._is_known
is_secret = await output._is_secret
value = await output._future

if not is_known:
result_resources.set_result(resources)
result_is_known.set_result(is_known)
result_is_secret.set_result(is_secret)
return cast(str, None)

# Try and dump using our special OutputEncoder to handle nested outputs
result = json.dumps(
value,
skipkeys=skipkeys,
ensure_ascii=ensure_ascii,
check_circular=check_circular,
allow_nan=allow_nan,
cls=OutputEncoder,
indent=indent,
separators=separators,
default=default,
sort_keys=sort_keys,
**kw
)

# Update the final resources and secret flag based on what we saw while dumping
is_secret = is_secret or seen_secret
resources = set(resources)
resources.update(seen_resources)

# If we saw an unknown during dumping then throw away the result and return not known
if seen_unknown:
result_resources.set_result(resources)
result_is_known.set_result(False)
result_is_secret.set_result(is_secret)
return cast(str, None)

result_resources.set_result(resources)
result_is_known.set_result(True)
result_is_secret.set_result(is_secret)
return result

finally:
with contextlib.suppress(asyncio.InvalidStateError):
result_resources.set_result(resources)

with contextlib.suppress(asyncio.InvalidStateError):
result_is_known.set_result(False)

with contextlib.suppress(asyncio.InvalidStateError):
result_is_secret.set_result(False)

run_fut = asyncio.ensure_future(run())
return Output(result_resources, run_fut, result_is_known, result_is_secret)

def __str__(self) -> str:
return """Calling __str__ on an Output[T] is not supported.

Expand Down
76 changes: 75 additions & 1 deletion sdk/python/lib/test/test_output.py
Expand Up @@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

import json
import asyncio
import unittest
from typing import Mapping, Optional, Sequence, cast
Expand Down Expand Up @@ -294,4 +295,77 @@ async def test_args_and_kwags(self):
s = Output.from_input("hi")
x = Output.format("{0}, {s}", i, s=s)
x_val = await x.future()
self.assertEqual(x_val, "1, hi")
self.assertEqual(x_val, "1, hi")

class OutputJsonTests(unittest.TestCase):
@pulumi_test
async def test_basic(self):
i = Output.from_input([0, 1])
x = Output.json_dumps(i)
self.assertEqual(await x.future(), "[0, 1]")
self.assertEqual(await x.is_secret(), False)
self.assertEqual(await x.is_known(), True)

# from_input handles _most_ nested outputs, so we need to use user defined types to "work around"
# that, which means we also need to use a custom encoder
class CustomClass(object):
def __init__(self, a, b):
self.a = a
self.b = b

class CustomEncoder(json.JSONEncoder):
def default(self, o):
if isinstance(o, OutputJsonTests.CustomClass):
return (o.a, o.b)
return json.JSONEncoder.default(self, o)

@pulumi_test
async def test_nested(self):
i = Output.from_input(OutputJsonTests.CustomClass(Output.from_input(0), Output.from_input(1)))
x = Output.json_dumps(i, cls=OutputJsonTests.CustomEncoder)
self.assertEqual(await x.future(), "[0, 1]")
self.assertEqual(await x.is_secret(), False)
self.assertEqual(await x.is_known(), True)

@pulumi_test
async def test_nested_unknown(self):
future = asyncio.Future()
future.set_result(None)
is_known = asyncio.Future()
is_known.set_result(False)
unknown = Output(resources=set(), future=future, is_known=is_known)

i = Output.from_input(OutputJsonTests.CustomClass(unknown, Output.from_input(1)))
x = Output.json_dumps(i, cls=OutputJsonTests.CustomEncoder)
self.assertEqual(await x.is_secret(), False)
self.assertEqual(await x.is_known(), False)

@pulumi_test
async def test_nested_secret(self):
future = asyncio.Future()
future.set_result(0)
future_true = asyncio.Future()
future_true.set_result(True)
inner = Output(resources=set(), future=future, is_known=future_true, is_secret=future_true)

i = Output.from_input(OutputJsonTests.CustomClass(inner, Output.from_input(1)))
x = Output.json_dumps(i, cls=OutputJsonTests.CustomEncoder)
self.assertEqual(await x.future(), "[0, 1]")
self.assertEqual(await x.is_secret(), True)
self.assertEqual(await x.is_known(), True)

@pulumi_test
async def test_nested_dependencies(self):
future = asyncio.Future()
future.set_result(0)
future_true = asyncio.Future()
future_true.set_result(True)
resource = object()
inner = Output(resources=set([resource]), future=future, is_known=future_true)

i = Output.from_input(OutputJsonTests.CustomClass(inner, Output.from_input(1)))
x = Output.json_dumps(i, cls=OutputJsonTests.CustomEncoder)
self.assertEqual(await x.future(), "[0, 1]")
self.assertEqual(await x.is_secret(), False)
self.assertEqual(await x.is_known(), True)
self.assertIn(resource, await x.resources())