Repository: meltano/sdk
Pull request #2345 (2024-03-26 11:39:08)

feat: `PropertiesList` can now behave as an iterable
In `tap-spotify`, all schema objects inherit from [a `CustomObject` class](https://github.com/Matatika/tap-spotify/blob/a816614ef79d94de8f96948df3cc81ad3e4656f6/tap_spotify/schemas/utils/custom_object.py) that offers some convenience methods, including allowing [one custom object to extend others](https://github.com/Matatika/tap-spotify/blob/master/tap_spotify/streams.py#L125) in order to produce a merged schema. The reason this class exists was because at the time there wasn't an obvious way to merge two or more property lists together, but I think its a pretty simple addition to get the functionality natively.

Given that, for example, [`Rank`](https://github.com/Matatika/tap-spotify/blob/a816614ef79d94de8f96948df3cc81ad3e4656f6/tap_spotify/schemas/utils/rank.py), [`SyncedAt`](https://github.com/Matatika/tap-spotify/blob/a816614ef79d94de8f96948df3cc81ad3e4656f6/tap_spotify/schemas/utils/synced_at.py), [`Track`](https://github.com/Matatika/tap-spotify/blob/a816614ef79d94de8f96948df3cc81ad3e4656f6/tap_spotify/schemas/track.py) and [`AudioFeatures`](https://github.com/Matatika/tap-spotify/blob/a816614ef79d94de8f96948df3cc81ad3e4656f6/tap_spotify/schemas/audio_features.py) are no longer classes extending `CustomObject` and instead are just `PropertiesList` instances

`track.py`
```py
"""Schema definitions for track objects."""

from singer_sdk import typing as th

from tap_spotify.schemas.album import AlbumObject
from tap_spotify.schemas.artist import ArtistObject
from tap_spotify.schemas.external import ExternalIdObject, ExternalUrlObject
from tap_spotify.schemas.restriction import RestrictionObject as TrackRestrictionObject


TrackObject = th.PropertiesList(
    th.Property("album", AlbumObject),
    th.Property("artists", th.ArrayType(ArtistObject)),
    th.Property("available_markets", th.ArrayType(th.StringType)),
    th.Property("disc_number", th.IntegerType),
    th.Property("duration_ms", th.IntegerType),
    th.Property("explicit", th.BooleanType),
    th.Property("external_ids", ExternalIdObject()),
    th.Property("external_urls", ExternalUrlObject),
    th.Property("href", th.StringType),
    th.Property("id", th.StringType),
    th.Property("is_local", th.BooleanType),
    th.Property("is_playable", th.BooleanType),
    # th.Property("linked_from", TrackObject),  # noqa: ERA001
    th.Property("name", th.StringType),
    th.Property("popularity", th.IntegerType),
    th.Property("preview_url", th.StringType),
    th.Property("restrictions", TrackRestrictionObject),
    th.Property("track_number", th.IntegerType),
    th.Property("type", th.StringType),
    th.Property("uri", th.StringType),
)
```

then

```py
schema = TrackObject.extend_with(Rank, SyncedAt, AudioFeaturesObject).schema
```

becomes

```py
schema = th.PropertiesList(*TrackObject, *Rank, *SyncedAt, *AudioFeaturesObject).to_dict()
```

<!-- readthedocs-preview meltano-sdk start -->
----
📚 Documentation preview 📚: https://meltano-sdk--2345.org.readthedocs.build/en/2345/

<!-- readthedocs-preview meltano-sdk end -->

codspeed-hq[bot]: ## [CodSpeed Performance Report](https://codspeed.io/meltano/sdk/branches/ReubenFrankel:feature/properties-list-iter)

### Merging #2345 will **not alter performance**

<sub>:warning: No base runs were found</sub>

<sub>Falling back to comparing <code>ReubenFrankel:feature/properties-list-iter</code> (ff6009b) with <code>main</code> (5ec5a18)</sub>



### Summary

`✅ 6` untouched benchmarks





ReubenFrankel: ```
>>> from singer_sdk import typing as th
>>> 
>>> pl1 = th.PropertiesList(th.Property("first", th.StringType))
>>> pl2 = th.PropertiesList(th.Property("second", th.IntegerType))
>>> pl3 = th.PropertiesList(th.Property("third", th.BooleanType))
>>> 
>>> th.PropertiesList(*pl1, *pl2, *pl3).to_dict()
{'type': 'object', 'properties': {'first': {'type': ['string', 'null']}, 'second': {'type': ['integer', 'null']}, 'third': {'type': ['boolean', 'null']}}}
>>> 
>>> th.PropertiesList(*pl2, *pl3, *pl1).to_dict()
{'type': 'object', 'properties': {'second': {'type': ['integer', 'null']}, 'third': {'type': ['boolean', 'null']}, 'first': {'type': ['string', 'null']}}}
>>> 
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/meltano/sdk/pull/2345?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) Report
Attention: Patch coverage is `50.00000%` with `1 lines` in your changes are missing coverage. Please review.
> Project coverage is 88.74%. Comparing base [(`61a5727`)](https://app.codecov.io/gh/meltano/sdk/commit/61a57272d38a92c3b344a09f23a4dff9e59a2c76?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) to head [(`4f31648`)](https://app.codecov.io/gh/meltano/sdk/pull/2345?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/meltano/sdk/pull/2345?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) | Patch % | Lines |
|---|---|---|
| [singer\_sdk/typing.py](https://app.codecov.io/gh/meltano/sdk/pull/2345?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano#diff-c2luZ2VyX3Nkay90eXBpbmcucHk=) | 50.00% | [1 Missing :warning: ](https://app.codecov.io/gh/meltano/sdk/pull/2345?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #2345      +/-   ##
==========================================
- Coverage   88.76%   88.74%   -0.02%     
==========================================
  Files          54       54              
  Lines        4769     4771       +2     
  Branches      928      928              
==========================================
+ Hits         4233     4234       +1     
- Misses        374      375       +1     
  Partials      162      162              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/meltano/sdk/pull/2345?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano).


Patch:
diff --git a/singer_sdk/typing.py b/singer_sdk/typing.py
index c2879177..7f1b2056 100644
--- a/singer_sdk/typing.py
+++ b/singer_sdk/typing.py
@@ -1017,6 +1017,14 @@ class PropertiesList(ObjectType):
         """
         self.wrapped[property.name] = property
 
+    def __iter__(self) -> t.Iterator[Property]:
+        """Iterate all properties of the property list.
+
+        Returns:
+            Iterator of properties.
+        """
+        return self.wrapped.values().__iter__()
+
 
 def to_jsonschema_type(
     from_type: str | sa.types.TypeEngine | type[sa.types.TypeEngine],


Test patch:
diff --git a/tests/core/test_typing.py b/tests/core/test_typing.py
index 837aff7f..15d56ccc 100644
--- a/tests/core/test_typing.py
+++ b/tests/core/test_typing.py
@@ -349,3 +349,15 @@ def test_to_sql_type(jsonschema_type, expected):
 def test_append_null(type_dict: dict, expected: dict):
     result = append_type(type_dict, "null")
     assert result == expected
+
+
+def test_iterate_properties_list():
+    primitive_property = Property("primitive", BooleanType)
+    object_property = Property("object", PropertiesList(Property("value", BooleanType)))
+    list_property = Property("list", ArrayType(BooleanType))
+
+    properties_list = PropertiesList(primitive_property, object_property, list_property)
+
+    assert primitive_property in properties_list
+    assert object_property in properties_list
+    assert list_property in properties_list