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

MOTOR-689: Add async wrapper for pymongo.encryption.ClientEncryption #103

Merged
merged 44 commits into from
Mar 31, 2021
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
ee7b490
added wrapper for Asyncio explicit encryption
guanlinzhou Mar 25, 2021
e252b38
add docs
guanlinzhou Mar 25, 2021
8fed6c1
syntax mistake
guanlinzhou Mar 25, 2021
1ef5c2a
add docstring
guanlinzhou Mar 25, 2021
3e11eb7
add tornado support
guanlinzhou Mar 25, 2021
dea8025
shorten docstring
guanlinzhou Mar 25, 2021
9a60728
expose close api
guanlinzhou Mar 26, 2021
75d2fdc
checkout
guanlinzhou Mar 29, 2021
9a58140
checkout
guanlinzhou Mar 29, 2021
a3575c0
get tests working
guanlinzhou Mar 29, 2021
bf05409
remove .eggs
guanlinzhou Mar 29, 2021
13f4cdb
newline
guanlinzhou Mar 29, 2021
4254aae
eof
guanlinzhou Mar 29, 2021
efddbe8
eof
guanlinzhou Mar 29, 2021
d3a73ab
cleanup calls back
guanlinzhou Mar 29, 2021
0c0e70b
cleanup calls back
guanlinzhou Mar 29, 2021
c6c7edd
updated travis config for installing pymongocrypt
guanlinzhou Mar 29, 2021
7f9b84a
add setup
guanlinzhou Mar 29, 2021
aea3442
augment evergreen testing
guanlinzhou Mar 29, 2021
0b2ca3b
fix config
guanlinzhou Mar 29, 2021
05050b1
fix test script
guanlinzhou Mar 30, 2021
180509b
virtualenv eg
guanlinzhou Mar 30, 2021
68bc359
fix
guanlinzhou Mar 30, 2021
b4849e0
fix python binary
guanlinzhou Mar 30, 2021
f2615ed
replace with pip install
guanlinzhou Mar 30, 2021
bb4cbe8
test if extra commands are needed
guanlinzhou Mar 30, 2021
310ff1c
test fix
guanlinzhou Mar 30, 2021
bcff10c
use createvenv
guanlinzhou Mar 30, 2021
29b0880
fix
guanlinzhou Mar 30, 2021
e0337ca
add testing lines
guanlinzhou Mar 30, 2021
d569bd0
install through tox
guanlinzhou Mar 30, 2021
ef043bb
restore dsi files
guanlinzhou Mar 30, 2021
e093948
replace config.yml
guanlinzhou Mar 30, 2021
64f68d7
remove extra newline
guanlinzhou Mar 30, 2021
a9b976b
nits
guanlinzhou Mar 30, 2021
7011887
Merge branch 'master' of github.com:mongodb/motor into PYTHON-689
guanlinzhou Mar 30, 2021
c4e81a3
update installation info
guanlinzhou Mar 30, 2021
9a03574
link to pymongocrypt
guanlinzhou Mar 30, 2021
171becc
single source python dependency
guanlinzhou Mar 31, 2021
26cf82f
extra
guanlinzhou Mar 31, 2021
81f87cd
revert changes
guanlinzhou Mar 31, 2021
3f5f880
fix failure with setUp
guanlinzhou Mar 31, 2021
87e711f
fix failure with setUp
guanlinzhou Mar 31, 2021
8f6026e
update older pip version
guanlinzhou Mar 31, 2021
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
1 change: 1 addition & 0 deletions .travis.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ services: mongodb

install:
- pip install tornado
- pip install pymongo[encryption]
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved

script: "python setup.py test"

Expand Down
7 changes: 7 additions & 0 deletions doc/api-asyncio/asyncio_motor_client_encryption.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
:class:`~motor.motor_asyncio.AsyncIOMotorClientEncryption`
==========================================================

.. currentmodule:: motor.motor_asyncio

.. autoclass:: AsyncIOMotorClientEncryption
:members:
1 change: 1 addition & 0 deletions doc/api-asyncio/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Motor asyncio API
asyncio_motor_database
asyncio_motor_collection
asyncio_motor_change_stream
asyncio_motor_client_encryption
cursors
asyncio_gridfs
aiohttp
Expand Down
1 change: 1 addition & 0 deletions doc/api-tornado/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ Motor Tornado API
motor_database
motor_collection
motor_change_stream
motor_client_encryption
cursors
gridfs
web
Expand Down
7 changes: 7 additions & 0 deletions doc/api-tornado/motor_client_encryption.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
:class:`~motor.motor_tornado.MotorClientEncryption`
===================================================

.. currentmodule:: motor.motor_tornado

.. autoclass:: MotorClientEncryption
:members:
49 changes: 48 additions & 1 deletion motor/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from pymongo.cursor import Cursor, RawBatchCursor, _QUERY_OPTIONS
from pymongo.database import Database
from pymongo.driver_info import DriverInfo
from pymongo.encryption import ClientEncryption

from . import version as motor_version
from .metaprogramming import (AsyncCommand,
Expand Down Expand Up @@ -140,7 +141,7 @@ def __init__(self, *args, **kwargs):

:Parameters:
- `io_loop` (optional): Special event loop
instance to use instead of default
instance to use instead of default.
"""
if 'io_loop' in kwargs:
io_loop = kwargs.pop('io_loop')
Expand Down Expand Up @@ -1795,3 +1796,49 @@ def __enter__(self):

def __exit__(self, exc_type, exc_val, exc_tb):
pass

class AgnosticClientEncryption(AgnosticBase):
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved
"""Explicit client-side field level encryption."""

__motor_class_name__ = 'MotorClientEncryption'
__delegate_class__ = ClientEncryption

create_data_key = AsyncCommand(doc=create_data_key_doc)
encrypt = AsyncCommand()
decrypt = AsyncCommand()
close = AsyncCommand(doc=close_doc)

def __init__(self, kms_providers, key_vault_namespace, key_vault_client, codec_options, io_loop=None):
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved
"""Explicit client-side field level encryption.

Takes the same constructor arguments as
:class:`pymongo.encryption.ClientEncryption`, as well as:

:Parameters:
- `io_loop` (optional): Special event loop
instance to use instead of default.
"""
if io_loop:
self._framework.check_event_loop(io_loop)
else:
io_loop = self._framework.get_event_loop()
sync_client = key_vault_client.delegate
delegate = self.__delegate_class__(kms_providers, key_vault_namespace, sync_client, codec_options)
super().__init__(delegate)
self.io_loop = io_loop

def get_io_loop(self):
return self.io_loop

async def __aenter__(self):
return self

async def __aexit__(self, exc_type, exc_val, exc_tb):
if self.delegate:
await self.close()

def __enter__(self):
raise RuntimeError('Use this encryption module in "async with", not "with"')
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved

def __exit__(self, exc_type, exc_val, exc_tb):
pass
29 changes: 29 additions & 0 deletions motor/docstrings.py
Original file line number Diff line number Diff line change
Expand Up @@ -1268,3 +1268,32 @@ async def coro():
.. _$expr: https://docs.mongodb.com/manual/reference/operator/query/expr/
.. _$where: https://docs.mongodb.com/manual/reference/operator/query/where/
"""

create_data_key_doc = """Create and insert a new data key into the key vault collection.

Takes the same constructors as
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved
:class:`pymongo.encryption.ClientEncryption.create_data_key`,
with only the following slight difference using async syntax:
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved

:Parameters:
- `key_alt_names` (optional): An optional list of string alternate
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved
names used to reference a key. If a key is created with alternate
names, then encryption may refer to the key by the unique alternate
name instead of by ``key_id``. The following example shows creating
and referring to a data key by alternate name::

await client_encryption.create_data_key("local", keyAltNames=["name1"])
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved
# reference the key with the alternate name
await client_encryption.encrypt("457-55-5462", keyAltName="name1",
algorithm=Algorithm.Random)
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved
"""

close_doc = """Release resources.

Note that using this class in a with-statement will automatically call
:meth:`close`::

async with AsyncIOMotorClientEncryption(...) as client_encryption:
encrypted = await client_encryption.encrypt(value, ...)
decrypted = await client_encryption.decrypt(encrypted)
"""
6 changes: 5 additions & 1 deletion motor/motor_asyncio.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from .frameworks import asyncio as asyncio_framework
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved
from .metaprogramming import create_class_with_framework

__all__ = ['AsyncIOMotorClient']
__all__ = ['AsyncIOMotorClient','AsyncIOMotorClientEncryption']


def create_asyncio_class(cls):
Expand Down Expand Up @@ -70,3 +70,7 @@ def create_asyncio_class(cls):

AsyncIOMotorGridOutCursor = create_asyncio_class(
motor_gridfs.AgnosticGridOutCursor)


AsyncIOMotorClientEncryption = create_asyncio_class(
core.AgnosticClientEncryption)
5 changes: 4 additions & 1 deletion motor/motor_tornado.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from .frameworks import tornado as tornado_framework
from .metaprogramming import create_class_with_framework

__all__ = ['MotorClient']
__all__ = ['MotorClient', 'MotorClientEncryption']


def create_motor_class(cls):
Expand Down Expand Up @@ -60,3 +60,6 @@ def create_motor_class(cls):


MotorGridOutCursor = create_motor_class(motor_gridfs.AgnosticGridOutCursor)


MotorClientEncryption = create_motor_class(core.AgnosticClientEncryption)
5 changes: 5 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@

tests_require = ['mockupdb>=1.4.0']

extras_require = {
'encryption': ['pymongo[encryption]>=3.11,<4'],
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to add the >=3.11,<4 here? I would hope that the version reqs are added transitively from pymongo in install_requires.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@prashantmital thoughts on this?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think extras are applied on top of (i.e. after) install_requiries so we would probably need this. I'll run a quick test and get back to you.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With a setup.py that looks like:

setup(
    name='mypackage',
    version='0.1.0',
    packages=find_packages(),
    ext_modules=get_extension_modules(),
    install_requires=['pymongo==3.10'],
    extras_require={'foo': ['pymongo[srv]']},
)

installing the package has a rather strange output log:

➜   pip install -e '.[foo]'
Obtaining file:///Users/pmital/Developer/playgrounds/python-packaging
Collecting pymongo==3.10
  Downloading pymongo-3.10.0-cp37-cp37m-macosx_10_9_x86_64.whl (350 kB)
     |████████████████████████████████| 350 kB 1.6 MB/s 
Collecting pymongo[srv]
  Using cached pymongo-3.11.3-cp37-cp37m-macosx_10_6_intel.whl (414 kB)
  Downloading pymongo-3.11.2-cp37-cp37m-macosx_10_6_intel.whl (414 kB)
     |████████████████████████████████| 414 kB 1.7 MB/s 
  Downloading pymongo-3.11.1-cp37-cp37m-macosx_10_6_intel.whl (414 kB)
     |████████████████████████████████| 414 kB 2.6 MB/s 
  Downloading pymongo-3.11.0-cp37-cp37m-macosx_10_9_x86_64.whl (378 kB)
     |████████████████████████████████| 378 kB 3.2 MB/s 
  Downloading pymongo-3.10.1-cp37-cp37m-macosx_10_9_x86_64.whl (350 kB)
     |████████████████████████████████| 350 kB 3.8 MB/s 
Collecting dnspython<2.0.0,>=1.16.0
  Using cached dnspython-1.16.0-py2.py3-none-any.whl (188 kB)
Installing collected packages: pymongo, dnspython, mypackage
  Attempting uninstall: mypackage
    Found existing installation: mypackage 0.1.0
    Uninstalling mypackage-0.1.0:
      Successfully uninstalled mypackage-0.1.0
  Running setup.py develop for mypackage
Successfully installed dnspython-1.16.0 mypackage pymongo-3.10.0
➜   pip list 
Package    Version Location
---------- ------- ----------------------------------------------------
Cython     0.29.22
dnspython  1.16.0
mypackage  0.1.0   /Users/pmital/Developer/playgrounds/python-packaging
pip        21.0.1
pymongo    3.10.0
setuptools 47.1.0
wheel      0.36.2

for some reason, it downloads all the versions between 3.10 and latest but ends up only installing the one in install_requires. So @ShaneHarvey does seem to be correct in that pip honors the install_requires version. That being said, it might result in longer install times if there are a lot versions to download.

Specifying the version in extras_require eliminates the extra downloads:

➜  pip --no-cache-dir install -e '.[foo]'
Obtaining file:///Users/pmital/Developer/playgrounds/python-packaging
Collecting pymongo==3.10
  Downloading pymongo-3.10.0-cp37-cp37m-macosx_10_9_x86_64.whl (350 kB)
     |████████████████████████████████| 350 kB 2.1 MB/s 
Requirement already satisfied: dnspython<2.0.0,>=1.16.0 in /Users/pmital/.pyenv/versions/3.7.10/envs/packaging/lib/python3.7/site-packages (from pymongo==3.10->mypackage==0.1.0) (1.16.0)
Installing collected packages: pymongo, mypackage
  Running setup.py develop for mypackage
Successfully installed mypackage pymongo-3.10.0

@ShaneHarvey how would you like to proceed?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh well. Let's keep >=3.11,<4. We'll just need to ensure those two reqs never drift apart.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see your concern. Let's single-source the PyMongo dependency. Maybe something like:

pymongo_ver = ">=3.11,<4"
install_requires = ["pymongo" + pymongo_ver]
extras_require = {'encryption': ["pymongo" + "[encryption]" + pymongo_ver]}

Thoughts @ShaneHarvey ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes let's do your single-source option.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@guanlinzhou can you implement this?

}

class test(Command):
description = "run the tests"

Expand Down Expand Up @@ -138,6 +142,7 @@ def run(self):
url='https://github.com/mongodb/motor/',
python_requires='>=3.5.2',
install_requires=install_requires,
extras_require=extras_require,
license='http://www.apache.org/licenses/LICENSE-2.0',
classifiers=[c for c in classifiers.split('\n') if c],
keywords=[
Expand Down
205 changes: 205 additions & 0 deletions test/asyncio_tests/test_asyncio_encryption.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
# Copyright 2021-present MongoDB, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Test Explicit Encryption with AsyncIOMotorClient."""

import unittest
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved
import uuid
from bson.binary import (Binary,
JAVA_LEGACY,
STANDARD,
UUID_SUBTYPE)
from bson.codec_options import CodecOptions
from bson.errors import BSONError

from motor.motor_asyncio import AsyncIOMotorClientEncryption

from pymongo.encryption import Algorithm
from pymongo.errors import InvalidOperation
from test.asyncio_tests import (asyncio_test,
AsyncIOTestCase,
skip_if_mongos)

KMS_PROVIDERS = {'local': {'key': b'\x00'*96}}

OPTS = CodecOptions(uuid_representation=STANDARD)

try:
import pymongocrypt
_HAVE_PYMONGOCRYPT = True
except ImportError:
_HAVE_PYMONGOCRYPT = False


@unittest.skipUnless(_HAVE_PYMONGOCRYPT, "pymongocrypt is a required dependency")
guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved
class TestExplicitSimple(AsyncIOTestCase):
def assertEncrypted(self, val):
self.assertIsInstance(val, Binary)
self.assertEqual(val.subtype, 6)

def assertBinaryUUID(self, val):
self.assertIsInstance(val, Binary)
self.assertEqual(val.subtype, UUID_SUBTYPE)

@asyncio_test
async def test_encrypt_decrypt(self):
client = self.asyncio_client()
client_encryption = AsyncIOMotorClientEncryption(
KMS_PROVIDERS, 'keyvault.datakeys', client, OPTS)
# Use standard UUID representation.
key_vault = client.keyvault.get_collection(
'datakeys', codec_options=OPTS)

# Create the encrypted field's data key.
key_id = await client_encryption.create_data_key(
'local', key_alt_names=['name'])
self.assertBinaryUUID(key_id)
self.assertTrue(await key_vault.find_one({'_id': key_id}))

# Create an unused data key to make sure filtering works.
unused_key_id = await client_encryption.create_data_key(
'local', key_alt_names=['unused'])
self.assertBinaryUUID(unused_key_id)
self.assertTrue(await key_vault.find_one({'_id': unused_key_id}))

doc = {'_id': 0, 'ssn': '000'}
encrypted_ssn = await client_encryption.encrypt(
doc['ssn'], Algorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic,
key_id=key_id)

# Ensure encryption via key_alt_name for the same key produces the
# same output.
encrypted_ssn2 = await client_encryption.encrypt(
doc['ssn'], Algorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic,
key_alt_name='name')
self.assertEqual(encrypted_ssn, encrypted_ssn2)

# Test decryption.
decrypted_ssn = await client_encryption.decrypt(encrypted_ssn)
self.assertEqual(decrypted_ssn, doc['ssn'])

await key_vault.drop()
await client_encryption.close()

@asyncio_test
async def test_validation(self):
client = self.asyncio_client()
client_encryption = AsyncIOMotorClientEncryption(
KMS_PROVIDERS, 'keyvault.datakeys', client, OPTS)

msg = 'value to decrypt must be a bson.binary.Binary with subtype 6'
with self.assertRaisesRegex(TypeError, msg):
await client_encryption.decrypt('str')
with self.assertRaisesRegex(TypeError, msg):
await client_encryption.decrypt(Binary(b'123'))

msg = 'key_id must be a bson.binary.Binary with subtype 4'
algo = Algorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic
with self.assertRaisesRegex(TypeError, msg):
await client_encryption.encrypt('str', algo, key_id=uuid.uuid4())
with self.assertRaisesRegex(TypeError, msg):
await client_encryption.encrypt('str', algo, key_id=Binary(b'123'))

await client_encryption.close()

@asyncio_test
async def test_bson_errors(self):
client = self.asyncio_client()
client_encryption = AsyncIOMotorClientEncryption(
KMS_PROVIDERS, 'keyvault.datakeys', client, OPTS)

# Attempt to encrypt an unencodable object.
unencodable_value = object()
with self.assertRaises(BSONError):
await client_encryption.encrypt(
unencodable_value,
Algorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic,
key_id=Binary(uuid.uuid4().bytes, UUID_SUBTYPE))

await client_encryption.close()

@asyncio_test
async def test_codec_options(self):
client = self.asyncio_client()
with self.assertRaisesRegex(TypeError, 'codec_options must be'):
AsyncIOMotorClientEncryption(
KMS_PROVIDERS, 'keyvault.datakeys', client, None)

opts = CodecOptions(uuid_representation=JAVA_LEGACY)
client_encryption_legacy = AsyncIOMotorClientEncryption(
KMS_PROVIDERS, 'keyvault.datakeys', client, opts)
# self.addCleanup(client_encryption_legacy.close)

# Create the encrypted field's data key.
key_id = await client_encryption_legacy.create_data_key('local')

# Encrypt a UUID with JAVA_LEGACY codec options.
value = uuid.uuid4()
encrypted_legacy = await client_encryption_legacy.encrypt(
value, Algorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic,
key_id=key_id)
decrypted_value_legacy = await client_encryption_legacy.decrypt(
encrypted_legacy)
self.assertEqual(decrypted_value_legacy, value)

# Encrypt the same UUID with STANDARD codec options.
client_encryption = AsyncIOMotorClientEncryption(
KMS_PROVIDERS, 'keyvault.datakeys', client, OPTS)
encrypted_standard = await client_encryption.encrypt(
value, Algorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic,
key_id=key_id)
decrypted_standard = await client_encryption.decrypt(encrypted_standard)
self.assertEqual(decrypted_standard, value)

# Test that codec_options is applied during encryption.
self.assertNotEqual(encrypted_standard, encrypted_legacy)
# Test that codec_options is applied during decryption.
self.assertEqual(
await client_encryption_legacy.decrypt(encrypted_standard), value)
self.assertNotEqual(
await client_encryption.decrypt(encrypted_legacy), value)

await client_encryption.close()

@asyncio_test
async def test_close(self):
client = self.asyncio_client()
client_encryption = AsyncIOMotorClientEncryption(
KMS_PROVIDERS, 'keyvault.datakeys', client, OPTS)
await client_encryption.close()
# Close can be called multiple times.
await client_encryption.close()
algo = Algorithm.AEAD_AES_256_CBC_HMAC_SHA_512_Deterministic
msg = 'Cannot use closed ClientEncryption'
with self.assertRaisesRegex(InvalidOperation, msg):
await client_encryption.create_data_key('local')
with self.assertRaisesRegex(InvalidOperation, msg):
await client_encryption.encrypt('val', algo, key_alt_name='name')
with self.assertRaisesRegex(InvalidOperation, msg):
await client_encryption.decrypt(Binary(b'', 6))

@asyncio_test
async def test_with_statement(self):
client = self.asyncio_client()
async with AsyncIOMotorClientEncryption(
KMS_PROVIDERS, 'keyvault.datakeys',
client, OPTS) as client_encryption:
pass
with self.assertRaisesRegex(
InvalidOperation, 'Cannot use closed ClientEncryption'):
await client_encryption.create_data_key('local')

guanlinzhou marked this conversation as resolved.
Show resolved Hide resolved

if __name__ == '__main__':
unittest.main()