Repository: googleapis/python-bigquery

Issue:
The DB API Binary function should accept bytes.
```
(3.8) jim@ds9:~/p/g/python-bigquery-sqlalchemy$ python
Python 3.8.5 (default, Jan 27 2021, 15:41:15) 
[GCC 9.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import google.cloud.bigquery.dbapi
>>> google.cloud.bigquery.dbapi.Binary(b'x')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/jim/p/g/python-bigquery/google/cloud/bigquery/dbapi/types.py", line 42, in Binary
    return string.encode("utf-8")
AttributeError: 'bytes' object has no attribute 'encode'
```
Bytes are the most common way to represent binary data.  Accepting strings, as it does now seems at best to be a convenience and at worst a bug magnet.

In SQLAlchemy, if you defined a model that has a binary attribute, you'd store bytes data in it, but that would break for bigquery, di to this issue.

Sqlite's `Binary` function requires bytes data.

I propose to change the function to accept bytes.  For the sake of backward compatibility, I propose to continue to accept strings.


Discussion:

Patch:
diff --git a/google/cloud/bigquery/dbapi/types.py b/google/cloud/bigquery/dbapi/types.py
index 20eca9b0..717593ae 100644
--- a/google/cloud/bigquery/dbapi/types.py
+++ b/google/cloud/bigquery/dbapi/types.py
@@ -30,16 +30,28 @@ DateFromTicks = datetime.date.fromtimestamp
 TimestampFromTicks = datetime.datetime.fromtimestamp
 
 
-def Binary(string):
+def Binary(data):
     """Contruct a DB-API binary value.
 
     Args:
-        string (str): A string to encode as a binary value.
+        data (bytes-like): An object containing binary data and that
+                           can be converted to bytes with the `bytes` builtin.
 
     Returns:
-        bytes: The UTF-8 encoded bytes representing the string.
+        bytes: The binary data as a bytes object.
     """
-    return string.encode("utf-8")
+    if isinstance(data, int):
+        # This is not the conversion we're looking for, because it
+        # will simply create a bytes object of the given size.
+        raise TypeError("cannot convert `int` object to binary")
+
+    try:
+        return bytes(data)
+    except TypeError:
+        if isinstance(data, str):
+            return data.encode("utf-8")
+        else:
+            raise
 
 
 def TimeFromTicks(ticks, tz=None):
diff --git a/samples/geography/requirements.txt b/samples/geography/requirements.txt
index 96819343..7e017e28 100644
--- a/samples/geography/requirements.txt
+++ b/samples/geography/requirements.txt
@@ -1,4 +1,4 @@
 geojson==2.5.0
-google-cloud-bigquery==2.13.1
+google-cloud-bigquery==2.14.0
 google-cloud-bigquery-storage==2.1.0
 Shapely==1.7.1
diff --git a/samples/snippets/requirements.txt b/samples/snippets/requirements.txt
index 74a18981..d7e60f77 100644
--- a/samples/snippets/requirements.txt
+++ b/samples/snippets/requirements.txt
@@ -1,4 +1,4 @@
-google-cloud-bigquery==2.13.1
+google-cloud-bigquery==2.14.0
 google-cloud-bigquery-storage==2.3.0
 google-auth-oauthlib==0.4.4
 grpcio==1.37.0


Test patch:
diff --git a/samples/snippets/conftest.py b/samples/snippets/conftest.py
index 31c6ba10..0d0299ee 100644
--- a/samples/snippets/conftest.py
+++ b/samples/snippets/conftest.py
@@ -20,21 +20,29 @@ import pytest
 
 
 RESOURCE_PREFIX = "python_bigquery_samples_snippets"
+RESOURCE_DATE_FORMAT = "%Y%m%d_%H%M%S"
+RESOURCE_DATE_LENGTH = 4 + 2 + 2 + 1 + 2 + 2 + 2
 
 
 def resource_prefix() -> str:
-    timestamp = datetime.datetime.utcnow().strftime("%Y%m%d_%H%M%S")
+    timestamp = datetime.datetime.utcnow().strftime(RESOURCE_DATE_FORMAT)
     random_string = hex(random.randrange(1000000))[2:]
     return f"{RESOURCE_PREFIX}_{timestamp}_{random_string}"
 
 
+def resource_name_to_date(resource_name: str):
+    start_date = len(RESOURCE_PREFIX) + 1
+    date_string = resource_name[start_date : start_date + RESOURCE_DATE_LENGTH]
+    return datetime.strptime(date_string, RESOURCE_DATE_FORMAT)
+
+
 @pytest.fixture(scope="session", autouse=True)
 def cleanup_datasets(bigquery_client: bigquery.Client):
     yesterday = datetime.datetime.utcnow() - datetime.timedelta(days=1)
     for dataset in bigquery_client.list_datasets():
         if (
             dataset.dataset_id.startswith(RESOURCE_PREFIX)
-            and dataset.created < yesterday
+            and resource_name_to_date(dataset.dataset_id) < yesterday
         ):
             bigquery_client.delete_dataset(
                 dataset, delete_contents=True, not_found_ok=True
diff --git a/tests/unit/test_dbapi_types.py b/tests/unit/test_dbapi_types.py
index e05660ff..cf282c68 100644
--- a/tests/unit/test_dbapi_types.py
+++ b/tests/unit/test_dbapi_types.py
@@ -15,6 +15,8 @@
 import datetime
 import unittest
 
+import pytest
+
 import google.cloud._helpers
 from google.cloud.bigquery.dbapi import types
 
@@ -26,10 +28,6 @@ class TestTypes(unittest.TestCase):
         self.assertEqual("STRUCT", types.BINARY)
         self.assertNotEqual("STRING", types.BINARY)
 
-    def test_binary_constructor(self):
-        self.assertEqual(types.Binary(u"hello"), b"hello")
-        self.assertEqual(types.Binary(u"\u1f60"), u"\u1f60".encode("utf-8"))
-
     def test_timefromticks(self):
         somedatetime = datetime.datetime(
             2017, 2, 18, 12, 47, 26, tzinfo=google.cloud._helpers.UTC
@@ -40,3 +38,29 @@ class TestTypes(unittest.TestCase):
             types.TimeFromTicks(ticks, google.cloud._helpers.UTC),
             datetime.time(12, 47, 26, tzinfo=google.cloud._helpers.UTC),
         )
+
+
+class CustomBinary:
+    def __bytes__(self):
+        return b"Google"
+
+
+@pytest.mark.parametrize(
+    "raw,expected",
+    [
+        (u"hello", b"hello"),
+        (u"\u1f60", u"\u1f60".encode("utf-8")),
+        (b"hello", b"hello"),
+        (bytearray(b"hello"), b"hello"),
+        (memoryview(b"hello"), b"hello"),
+        (CustomBinary(), b"Google"),
+    ],
+)
+def test_binary_constructor(raw, expected):
+    assert types.Binary(raw) == expected
+
+
+@pytest.mark.parametrize("bad", (42, 42.0, None))
+def test_invalid_binary_constructor(bad):
+    with pytest.raises(TypeError):
+        types.Binary(bad)