Skip to content

Commit f1a1189

Browse files
author
Chris Rossi
authored
fix: resurrect support for compressed text property (#342)
Fixes #277
1 parent 04d67f7 commit f1a1189

File tree

4 files changed

+255
-1
lines changed

4 files changed

+255
-1
lines changed

packages/google-cloud-ndb/google/cloud/ndb/model.py

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ class Person(Model):
294294
"IntegerProperty",
295295
"FloatProperty",
296296
"BlobProperty",
297+
"CompressedTextProperty",
297298
"TextProperty",
298299
"StringProperty",
299300
"GeoPtProperty",
@@ -2558,6 +2559,129 @@ def _db_set_uncompressed_meaning(self, p):
25582559
raise exceptions.NoLongerImplementedError()
25592560

25602561

2562+
class CompressedTextProperty(BlobProperty):
2563+
"""A version of :class:`TextProperty` which compresses values.
2564+
2565+
Values are stored as ``zlib`` compressed UTF-8 byte sequences rather than
2566+
as strings as in a regular :class:`TextProperty`. This class allows NDB to
2567+
support passing `compressed=True` to :class:`TextProperty`. It is not
2568+
necessary to instantiate this class directly.
2569+
"""
2570+
2571+
__slots__ = ()
2572+
2573+
def __init__(self, *args, **kwargs):
2574+
indexed = kwargs.pop("indexed", False)
2575+
if indexed:
2576+
raise NotImplementedError(
2577+
"A TextProperty cannot be indexed. Previously this was "
2578+
"allowed, but this usage is no longer supported."
2579+
)
2580+
2581+
kwargs["compressed"] = True
2582+
super(CompressedTextProperty, self).__init__(*args, **kwargs)
2583+
2584+
def _constructor_info(self):
2585+
"""Helper for :meth:`__repr__`.
2586+
2587+
Yields:
2588+
Tuple[str, bool]: Pairs of argument name and a boolean indicating
2589+
if that argument is a keyword.
2590+
"""
2591+
parent_init = super(CompressedTextProperty, self).__init__
2592+
# inspect.signature not available in Python 2.7, so we use positional
2593+
# decorator combined with argspec instead.
2594+
argspec = getattr(
2595+
parent_init, "_argspec", inspect.getargspec(parent_init)
2596+
)
2597+
positional = getattr(parent_init, "_positional_args", 1)
2598+
for index, name in enumerate(argspec.args):
2599+
if name in ("self", "indexed", "compressed"):
2600+
continue
2601+
yield name, index >= positional
2602+
2603+
@property
2604+
def _indexed(self):
2605+
"""bool: Indicates that the property is not indexed."""
2606+
return False
2607+
2608+
def _validate(self, value):
2609+
"""Validate a ``value`` before setting it.
2610+
2611+
Args:
2612+
value (Union[bytes, str]): The value to check.
2613+
2614+
Raises:
2615+
.BadValueError: If ``value`` is :class:`bytes`, but is not a valid
2616+
UTF-8 encoded string.
2617+
.BadValueError: If ``value`` is neither :class:`bytes` nor
2618+
:class:`str`.
2619+
.BadValueError: If the current property is indexed but the UTF-8
2620+
encoded value exceeds the maximum length (1500 bytes).
2621+
"""
2622+
if not isinstance(value, six.text_type):
2623+
# In Python 2.7, bytes is a synonym for str
2624+
if isinstance(value, bytes):
2625+
try:
2626+
value = value.decode("utf-8")
2627+
except UnicodeError:
2628+
raise exceptions.BadValueError(
2629+
"Expected valid UTF-8, got {!r}".format(value)
2630+
)
2631+
else:
2632+
raise exceptions.BadValueError(
2633+
"Expected string, got {!r}".format(value)
2634+
)
2635+
2636+
def _to_base_type(self, value):
2637+
"""Convert a value to the "base" value type for this property.
2638+
2639+
Args:
2640+
value (Union[bytes, str]): The value to be converted.
2641+
2642+
Returns:
2643+
Optional[bytes]: The converted value. If ``value`` is a
2644+
:class:`str`, this will return the UTF-8 encoded bytes for it.
2645+
Otherwise, it will return :data:`None`.
2646+
"""
2647+
if isinstance(value, six.text_type):
2648+
return value.encode("utf-8")
2649+
2650+
def _from_base_type(self, value):
2651+
"""Convert a value from the "base" value type for this property.
2652+
2653+
.. note::
2654+
2655+
Older versions of ``ndb`` could write non-UTF-8 ``TEXT``
2656+
properties. This means that if ``value`` is :class:`bytes`, but is
2657+
not a valid UTF-8 encoded string, it can't (necessarily) be
2658+
rejected. But, :meth:`_validate` now rejects such values, so it's
2659+
not possible to write new non-UTF-8 ``TEXT`` properties.
2660+
2661+
Args:
2662+
value (Union[bytes, str]): The value to be converted.
2663+
2664+
Returns:
2665+
Optional[str]: The converted value. If ``value`` is a valid UTF-8
2666+
encoded :class:`bytes` string, this will return the decoded
2667+
:class:`str` corresponding to it. Otherwise, it will return
2668+
:data:`None`.
2669+
"""
2670+
if isinstance(value, bytes):
2671+
try:
2672+
return value.decode("utf-8")
2673+
except UnicodeError:
2674+
pass
2675+
2676+
def _db_set_uncompressed_meaning(self, p):
2677+
"""Helper for :meth:`_db_set_value`.
2678+
2679+
Raises:
2680+
NotImplementedError: Always. This method is virtual.
2681+
"""
2682+
raise NotImplementedError
2683+
2684+
25612685
class TextProperty(Property):
25622686
"""An unindexed property that contains UTF-8 encoded text values.
25632687
@@ -2578,10 +2702,37 @@ class Item(ndb.Model):
25782702
.. automethod:: _from_base_type
25792703
.. automethod:: _validate
25802704
2705+
Args:
2706+
name (str): The name of the property.
2707+
compressed (bool): Indicates if the value should be compressed (via
2708+
``zlib``). An instance of :class:`CompressedTextProperty` will be
2709+
substituted if `True`.
2710+
indexed (bool): Indicates if the value should be indexed.
2711+
repeated (bool): Indicates if this property is repeated, i.e. contains
2712+
multiple values.
2713+
required (bool): Indicates if this property is required on the given
2714+
model type.
2715+
default (Any): The default value for this property.
2716+
choices (Iterable[Any]): A container of allowed values for this
2717+
property.
2718+
validator (Callable[[~google.cloud.ndb.model.Property, Any], bool]): A
2719+
validator to be used to check values.
2720+
verbose_name (str): A longer, user-friendly name for this property.
2721+
write_empty_list (bool): Indicates if an empty list should be written
2722+
to the datastore.
2723+
25812724
Raises:
25822725
NotImplementedError: If ``indexed=True`` is provided.
25832726
"""
25842727

2728+
def __new__(cls, *args, **kwargs):
2729+
# If "compressed" is True, substitute CompressedTextProperty
2730+
compressed = kwargs.get("compressed", False)
2731+
if compressed:
2732+
return CompressedTextProperty(*args, **kwargs)
2733+
2734+
return super(TextProperty, cls).__new__(cls)
2735+
25852736
def __init__(self, *args, **kwargs):
25862737
indexed = kwargs.pop("indexed", False)
25872738
if indexed:

packages/google-cloud-ndb/noxfile.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ def unit(session):
5353
"--cov=tests.unit",
5454
"--cov-config",
5555
get_path(".coveragerc"),
56-
"--cov-report=",
56+
"--cov-report=term-missing",
5757
]
5858
)
5959
run_args.append(get_path("tests", "unit"))

packages/google-cloud-ndb/tests/system/test_crud.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1195,3 +1195,24 @@ def delete_them(entities):
11951195
assert delete_them(entities) is None
11961196
entities = ndb.get_multi(keys)
11971197
assert entities == [None] * N
1198+
1199+
1200+
@pytest.mark.usefixtures("client_context")
1201+
def test_compressed_text_property(dispose_of, ds_client):
1202+
"""Regression test for #277
1203+
1204+
https://github.com/googleapis/python-ndb/issues/277
1205+
"""
1206+
1207+
class SomeKind(ndb.Model):
1208+
foo = ndb.TextProperty(compressed=True)
1209+
1210+
entity = SomeKind(foo="Compress this!")
1211+
key = entity.put()
1212+
dispose_of(key._key)
1213+
1214+
retrieved = key.get()
1215+
assert retrieved.foo == "Compress this!"
1216+
1217+
ds_entity = ds_client.get(key._key)
1218+
assert zlib.decompress(ds_entity["foo"]) == b"Compress this!"

packages/google-cloud-ndb/tests/unit/test_model.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1960,6 +1960,83 @@ class ThisKind(model.Model):
19601960
assert ds_entity["foo"] == [compressed_value_one, compressed_value_two]
19611961

19621962

1963+
class TestCompressedTextProperty:
1964+
@staticmethod
1965+
def test_constructor_defaults():
1966+
prop = model.CompressedTextProperty()
1967+
assert not prop._indexed
1968+
assert prop._compressed
1969+
1970+
@staticmethod
1971+
def test_constructor_explicit():
1972+
prop = model.CompressedTextProperty(name="text", indexed=False)
1973+
assert prop._name == "text"
1974+
assert not prop._indexed
1975+
1976+
@staticmethod
1977+
def test_constructor_not_allowed():
1978+
with pytest.raises(NotImplementedError):
1979+
model.CompressedTextProperty(indexed=True)
1980+
1981+
@staticmethod
1982+
def test_repr():
1983+
prop = model.CompressedTextProperty(name="text")
1984+
expected = "CompressedTextProperty('text')"
1985+
assert repr(prop) == expected
1986+
1987+
@staticmethod
1988+
def test__validate():
1989+
prop = model.CompressedTextProperty(name="text")
1990+
assert prop._validate(u"abc") is None
1991+
1992+
@staticmethod
1993+
def test__validate_bad_bytes():
1994+
prop = model.CompressedTextProperty(name="text")
1995+
value = b"\x80abc"
1996+
with pytest.raises(exceptions.BadValueError):
1997+
prop._validate(value)
1998+
1999+
@staticmethod
2000+
def test__validate_bad_type():
2001+
prop = model.CompressedTextProperty(name="text")
2002+
with pytest.raises(exceptions.BadValueError):
2003+
prop._validate(None)
2004+
2005+
@staticmethod
2006+
def test__to_base_type():
2007+
prop = model.CompressedTextProperty(name="text")
2008+
assert prop._to_base_type(b"abc") is None
2009+
2010+
@staticmethod
2011+
def test__to_base_type_converted():
2012+
prop = model.CompressedTextProperty(name="text")
2013+
value = b"\xe2\x98\x83"
2014+
assert prop._to_base_type(u"\N{snowman}") == value
2015+
2016+
@staticmethod
2017+
def test__from_base_type():
2018+
prop = model.CompressedTextProperty(name="text")
2019+
assert prop._from_base_type(u"abc") is None
2020+
2021+
@staticmethod
2022+
def test__from_base_type_converted():
2023+
prop = model.CompressedTextProperty(name="text")
2024+
value = b"\xe2\x98\x83"
2025+
assert prop._from_base_type(value) == u"\N{snowman}"
2026+
2027+
@staticmethod
2028+
def test__from_base_type_cannot_convert():
2029+
prop = model.CompressedTextProperty(name="text")
2030+
value = b"\x80abc"
2031+
assert prop._from_base_type(value) is None
2032+
2033+
@staticmethod
2034+
def test__db_set_uncompressed_meaning():
2035+
prop = model.CompressedTextProperty(name="text")
2036+
with pytest.raises(NotImplementedError):
2037+
prop._db_set_uncompressed_meaning(None)
2038+
2039+
19632040
class TestTextProperty:
19642041
@staticmethod
19652042
def test_constructor_defaults():
@@ -1977,6 +2054,11 @@ def test_constructor_not_allowed():
19772054
with pytest.raises(NotImplementedError):
19782055
model.TextProperty(indexed=True)
19792056

2057+
@staticmethod
2058+
def test_constructor_compressed():
2059+
prop = model.TextProperty(compressed=True)
2060+
assert isinstance(prop, model.CompressedTextProperty)
2061+
19802062
@staticmethod
19812063
def test_repr():
19822064
prop = model.TextProperty(name="text")

0 commit comments

Comments
 (0)