Repository: googleapis/python-bigquery

Issue:
SchemaField cannot initialize from __repr__

#### Environment details

  - OS type and version: macOS Big Sur 11.6.1
  - Python version: `3.9.7`
  - pip version: `21.3.1`
  - `google-cloud-bigquery` version: `2.29.0`

#### Description

Prior versions of `google-cloud-bigquery` (`2.3.1`) allowed `SchemaField` instantiation based on an existing `SchemaField` `__repr__`.

#### Use Case

1. System generates list of `SchemaField` and passes it through a Jinja template as part of building hundreds of Airflow DAGs.
2. Jinja prints `__repr__` from the `SchemaField` into the templated `.py`. 
3. Airflow picks up the `.py` files and executes them. 
4. Execution results in the below error.

#### Code example

In version `2.3.1`

```python
from google.cloud.bigquery import SchemaField
from google.cloud import bigquery
bigquery.__version__
'2.3.1'

# This works
field = SchemaField('name', 'STRING', 'REQUIRED', 'Name') 
field.__repr__()
"SchemaField('name', 'STRING', 'REQUIRED', 'Name', (), ())"
# Jinja prints ^^ to a .py file
# When the file is executed the SchemaField is created without issue
```

Version `2.29.0`:

```python
from google.cloud.bigquery import SchemaField
from google.cloud import bigquery
bigquery.__version__
'2.29.0'

# This raises an error for the tuple in the last field
field = SchemaField('name', 'STRING', 'REQUIRED', 'Name') 
field.__repr__()
"SchemaField('name', 'STRING', 'REQUIRED', 'Name', (), ())"
# Jinja prints ^^ to a .py file
# When the file is executed, an error is raised:

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/Users/wes.roach/.pyenv/versions/upgr-dbt/lib/python3.9/site-packages/google/cloud/bigquery/schema.py", line 125, in __init__
    policy_tags.to_api_repr() if policy_tags is not None else None
AttributeError: 'tuple' object has no attribute 'to_api_repr'
```

Setting the `policy_tags` to `None` still results in `()`.

```python
field = SchemaField('name', 'STRING', 'REQUIRED', 'Name', (), None)
field.__repr__()
"SchemaField('name', 'STRING', 'REQUIRED', 'Name', (), ())"
```


Discussion:
@WesRoach Thanks for the report! This does appear to be a bug introduced when policy tags were added.
@plamut Potential project for you if you have cycles? It'd be good to get this in v2.
@tswast I'll take a look, yes.
> This does appear to be a bug introduced when policy tags were added.

Confirmed, the regression was caused by https://github.com/googleapis/python-bigquery/pull/983.

Resolving patch:
diff --git a/google/cloud/bigquery/schema.py b/google/cloud/bigquery/schema.py
--- a/google/cloud/bigquery/schema.py
+++ b/google/cloud/bigquery/schema.py
@@ -268,7 +268,7 @@ def _key(self):
                     field_type = f"{field_type}({self.precision})"
 
         policy_tags = (
-            () if self.policy_tags is None else tuple(sorted(self.policy_tags.names))
+            None if self.policy_tags is None else tuple(sorted(self.policy_tags.names))
         )
 
         return (
@@ -336,7 +336,11 @@ def __hash__(self):
         return hash(self._key())
 
     def __repr__(self):
-        return "SchemaField{}".format(self._key())
+        key = self._key()
+        policy_tags = key[-1]
+        policy_tags_inst = None if policy_tags is None else PolicyTagList(policy_tags)
+        adjusted_key = key[:-1] + (policy_tags_inst,)
+        return f"{self.__class__.__name__}{adjusted_key}"
 
 
 def _parse_schema_resource(info):
@@ -407,7 +411,7 @@ class PolicyTagList(object):
             `projects/*/locations/*/taxonomies/*/policyTags/*`.
     """
 
-    def __init__(self, names=()):
+    def __init__(self, names: Iterable[str] = ()):
         self._properties = {}
         self._properties["names"] = tuple(names)
 
@@ -425,7 +429,7 @@ def _key(self):
         Returns:
             Tuple: The contents of this :class:`~google.cloud.bigquery.schema.PolicyTagList`.
         """
-        return tuple(sorted(self._properties.items()))
+        return tuple(sorted(self._properties.get("names", ())))
 
     def __eq__(self, other):
         if not isinstance(other, PolicyTagList):
@@ -439,7 +443,7 @@ def __hash__(self):
         return hash(self._key())
 
     def __repr__(self):
-        return "PolicyTagList{}".format(self._key())
+        return f"{self.__class__.__name__}(names={self._key()})"
 
     @classmethod
     def from_api_repr(cls, api_repr: dict) -> "PolicyTagList":
@@ -478,5 +482,5 @@ def to_api_repr(self) -> dict:
                 A dictionary representing the PolicyTagList object in
                 serialized form.
         """
-        answer = {"names": [name for name in self.names]}
+        answer = {"names": list(self.names)}
         return answer


Test patch:
diff --git a/tests/unit/test_schema.py b/tests/unit/test_schema.py
--- a/tests/unit/test_schema.py
+++ b/tests/unit/test_schema.py
@@ -510,9 +510,30 @@ def test___hash__not_equals(self):
 
     def test___repr__(self):
         field1 = self._make_one("field1", "STRING")
-        expected = "SchemaField('field1', 'STRING', 'NULLABLE', None, (), ())"
+        expected = "SchemaField('field1', 'STRING', 'NULLABLE', None, (), None)"
         self.assertEqual(repr(field1), expected)
 
+    def test___repr__evaluable_no_policy_tags(self):
+        field = self._make_one("field1", "STRING", "REQUIRED", "Description")
+        field_repr = repr(field)
+        SchemaField = self._get_target_class()  # needed for eval  # noqa
+
+        evaled_field = eval(field_repr)
+
+        assert field == evaled_field
+
+    def test___repr__evaluable_with_policy_tags(self):
+        policy_tags = PolicyTagList(names=["foo", "bar"])
+        field = self._make_one(
+            "field1", "STRING", "REQUIRED", "Description", policy_tags=policy_tags,
+        )
+        field_repr = repr(field)
+        SchemaField = self._get_target_class()  # needed for eval  # noqa
+
+        evaled_field = eval(field_repr)
+
+        assert field == evaled_field
+
 
 # TODO: dedup with the same class in test_table.py.
 class _SchemaBase(object):
@@ -786,6 +807,34 @@ def test___hash__not_equals(self):
         set_two = {policy2}
         self.assertNotEqual(set_one, set_two)
 
+    def test___repr__no_tags(self):
+        policy = self._make_one()
+        assert repr(policy) == "PolicyTagList(names=())"
+
+    def test___repr__with_tags(self):
+        policy1 = self._make_one(["foo", "bar", "baz"])
+        policy2 = self._make_one(["baz", "bar", "foo"])
+        expected_repr = "PolicyTagList(names=('bar', 'baz', 'foo'))"  # alphabetical
+
+        assert repr(policy1) == expected_repr
+        assert repr(policy2) == expected_repr
+
+    def test___repr__evaluable_no_tags(self):
+        policy = self._make_one(names=[])
+        policy_repr = repr(policy)
+
+        evaled_policy = eval(policy_repr)
+
+        assert policy == evaled_policy
+
+    def test___repr__evaluable_with_tags(self):
+        policy = self._make_one(names=["foo", "bar"])
+        policy_repr = repr(policy)
+
+        evaled_policy = eval(policy_repr)
+
+        assert policy == evaled_policy
+
 
 @pytest.mark.parametrize(
     "api,expect,key2",