ucapi 0.5.2__tar.gz → 0.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. {ucapi-0.5.2 → ucapi-0.7.0}/CHANGELOG.md +21 -0
  2. {ucapi-0.5.2/ucapi.egg-info → ucapi-0.7.0}/PKG-INFO +2 -2
  3. {ucapi-0.5.2 → ucapi-0.7.0}/pyproject.toml +1 -1
  4. ucapi-0.7.0/scripts/git-tag.py +168 -0
  5. {ucapi-0.5.2 → ucapi-0.7.0}/test-requirements.txt +1 -1
  6. {ucapi-0.5.2 → ucapi-0.7.0}/tests/test_api.py +50 -10
  7. ucapi-0.7.0/tests/test_media_player.py +159 -0
  8. ucapi-0.7.0/tests/test_paging.py +131 -0
  9. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/__init__.py +16 -1
  10. ucapi-0.7.0/ucapi/_version.py +24 -0
  11. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/api.py +684 -129
  12. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/api_definitions.py +145 -18
  13. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/button.py +11 -4
  14. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/climate.py +14 -7
  15. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/cover.py +14 -7
  16. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/entities.py +4 -0
  17. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/entity.py +31 -3
  18. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/ir_emitter.py +13 -7
  19. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/light.py +14 -7
  20. ucapi-0.7.0/ucapi/media_player.py +738 -0
  21. ucapi-0.7.0/ucapi/msg_definitions.py +68 -0
  22. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/remote.py +13 -6
  23. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/select.py +13 -7
  24. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/sensor.py +14 -7
  25. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/switch.py +14 -7
  26. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/ui.py +2 -2
  27. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/voice_assistant.py +15 -8
  28. {ucapi-0.5.2 → ucapi-0.7.0/ucapi.egg-info}/PKG-INFO +2 -2
  29. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi.egg-info/SOURCES.txt +4 -0
  30. ucapi-0.5.2/ucapi/_version.py +0 -34
  31. ucapi-0.5.2/ucapi/media_player.py +0 -233
  32. {ucapi-0.5.2 → ucapi-0.7.0}/CONTRIBUTING.md +0 -0
  33. {ucapi-0.5.2 → ucapi-0.7.0}/LICENSE +0 -0
  34. {ucapi-0.5.2 → ucapi-0.7.0}/README.md +0 -0
  35. {ucapi-0.5.2 → ucapi-0.7.0}/docs/code_guidelines.md +0 -0
  36. {ucapi-0.5.2 → ucapi-0.7.0}/docs/setup.md +0 -0
  37. {ucapi-0.5.2 → ucapi-0.7.0}/examples/README.md +0 -0
  38. {ucapi-0.5.2 → ucapi-0.7.0}/examples/hello_integration.json +0 -0
  39. {ucapi-0.5.2 → ucapi-0.7.0}/examples/hello_integration.py +0 -0
  40. {ucapi-0.5.2 → ucapi-0.7.0}/examples/remote.json +0 -0
  41. {ucapi-0.5.2 → ucapi-0.7.0}/examples/remote.py +0 -0
  42. {ucapi-0.5.2 → ucapi-0.7.0}/examples/remote_ui_page.json +0 -0
  43. {ucapi-0.5.2 → ucapi-0.7.0}/examples/setup_flow.json +0 -0
  44. {ucapi-0.5.2 → ucapi-0.7.0}/examples/setup_flow.py +0 -0
  45. {ucapi-0.5.2 → ucapi-0.7.0}/examples/voice.json +0 -0
  46. {ucapi-0.5.2 → ucapi-0.7.0}/examples/voice.py +0 -0
  47. {ucapi-0.5.2 → ucapi-0.7.0}/requirements.txt +0 -0
  48. {ucapi-0.5.2 → ucapi-0.7.0}/scripts/compile_protos.py +0 -0
  49. {ucapi-0.5.2 → ucapi-0.7.0}/setup.cfg +0 -0
  50. {ucapi-0.5.2 → ucapi-0.7.0}/tests/test_voice_assistant.py +0 -0
  51. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/proto/__init__.py +0 -0
  52. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/proto/ucr_integration_voice.proto +0 -0
  53. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/proto/ucr_integration_voice_pb2.py +0 -0
  54. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/proto/ucr_integration_voice_pb2.pyi +0 -0
  55. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/voice_stream.py +0 -0
  56. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi.egg-info/dependency_links.txt +0 -0
  57. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi.egg-info/requires.txt +0 -0
  58. {ucapi-0.5.2 → ucapi-0.7.0}/ucapi.egg-info/top_level.txt +0 -0
@@ -11,6 +11,27 @@ _Changes in the next release_
11
11
 
12
12
  ---
13
13
 
14
+ ## v0.7.0 - 2026-05-10
15
+ ### Added
16
+ - Add requests for supported entity types, version and localization. Only send available entities with supported entity types by @albaintor and @kennymc-c ([#47](https://github.com/unfoldedcircle/integration-python-library/pull/47)).
17
+
18
+ ### Changed
19
+ - Improved WS msg processing with dedicated consumer, producer and router tasks with asyncio queues ([#47](https://github.com/unfoldedcircle/integration-python-library/pull/47)).
20
+ - Sanitize log messages to prevent sensitive information exposure ([#56](https://github.com/unfoldedcircle/integration-python-library/pull/56)).
21
+ - Log WebSocket messages as JSON data instead of a Python dict ([#58](https://github.com/unfoldedcircle/integration-python-library/pull/58)).
22
+ - Updated GitHub actions and test dependencies.
23
+
24
+ ## v0.6.0 - 2026-04-10
25
+ ### Breaking Changes
26
+ - Renamed `MediaType` to `MediaContentType` and changed enums to lowercase. See media-player entity documentation for more information ([#50](https://github.com/unfoldedcircle/integration-python-library/pull/50)).
27
+ - Changed `str, Enum` to new Python 3.11 `StrEnum` class ([#54](https://github.com/unfoldedcircle/integration-python-library/pull/54)).
28
+ - All entity constructors require named parameters for the optional fields ([#49](https://github.com/unfoldedcircle/integration-python-library/pull/49), [#54](https://github.com/unfoldedcircle/integration-python-library/pull/54)).
29
+
30
+ ### Added
31
+ - Media browsing and searching features to media-player entity ([#50](https://github.com/unfoldedcircle/integration-python-library/pull/50)).
32
+ - Allow integrations to provide entity icon and description ([#55](https://github.com/unfoldedcircle/integration-python-library/pull/55)).
33
+
34
+
14
35
  ## v0.5.2 - 2026-01-30
15
36
  ### Added
16
37
  - Add Select Entity support by @JackJPowell ([#44](https://github.com/unfoldedcircle/integration-python-library/pull/44)).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ucapi
3
- Version: 0.5.2
3
+ Version: 0.7.0
4
4
  Summary: Python wrapper for the Unfolded Circle Integration API
5
5
  Author-email: Unfolded Circle ApS <hello@unfoldedcircle.com>
6
6
  License: MPL-2.0
@@ -10,7 +10,7 @@ Project-URL: Bug Reports, https://github.com/unfoldedcircle/integration-python-l
10
10
  Project-URL: Discord, http://unfolded.chat/
11
11
  Project-URL: Forum, https://unfolded.community/
12
12
  Platform: any
13
- Classifier: Development Status :: 4 - Beta
13
+ Classifier: Development Status :: 5 - Production/Stable
14
14
  Classifier: Intended Audience :: Developers
15
15
  Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
16
16
  Classifier: Operating System :: OS Independent
@@ -10,7 +10,7 @@ authors = [
10
10
  license = {text = "MPL-2.0"}
11
11
  description = "Python wrapper for the Unfolded Circle Integration API"
12
12
  classifiers = [
13
- "Development Status :: 4 - Beta",
13
+ "Development Status :: 5 - Production/Stable",
14
14
  "Intended Audience :: Developers",
15
15
  "License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
16
16
  "Operating System :: OS Independent",
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Git tag release script to simplify creating git tags with changelog since last release.
4
+ Copyright (c) 2026 Unfolded Circle.
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ import sys
10
+ import tempfile
11
+ import re
12
+ import json
13
+
14
+
15
+ def run_command(command, check=True):
16
+ """Run a shell command and return the output."""
17
+ try:
18
+ result = subprocess.run(
19
+ command, shell=True, check=check, capture_output=True, text=True
20
+ )
21
+ return result.stdout.strip()
22
+ except subprocess.CalledProcessError as e:
23
+ print(f"Error running command: {command}")
24
+ print(f"Stdout: {e.stdout}")
25
+ print(f"Stderr: {e.stderr}")
26
+ if check:
27
+ sys.exit(1)
28
+ return None
29
+
30
+
31
+ def get_latest_tag():
32
+ """Get the latest git tag."""
33
+ return run_command("git describe --tags --abbrev=0", check=False)
34
+
35
+
36
+ def get_commits_since(tag):
37
+ """Get commits since the specified tag."""
38
+ format_str = '--pretty=format:"%s|%an"'
39
+ if tag:
40
+ return run_command(f"git log {tag}..HEAD {format_str}")
41
+ else:
42
+ return run_command(f"git log {format_str}")
43
+
44
+
45
+ def is_valid_semver(tag):
46
+ """Check if the version is a valid semver format (X.Y.Z)."""
47
+ return re.match(r"^\d+\.\d+\.\d+$", tag) is not None
48
+
49
+
50
+ import argparse
51
+
52
+
53
+ def main():
54
+ parser = argparse.ArgumentParser(
55
+ description="Git tag script to simplify creating git tags."
56
+ )
57
+ parser.add_argument(
58
+ "version", help="The new version in semver format (e.g., 0.21.0)"
59
+ )
60
+ parser.add_argument(
61
+ "--dry-run", action="store_true", help="Do not create or push the tag"
62
+ )
63
+ args = parser.parse_args()
64
+
65
+ version = args.version
66
+ dry_run = args.dry_run
67
+ if not is_valid_semver(version):
68
+ print(f"Error: Version '{version}' is not in valid semver format (X.Y.Z)")
69
+ sys.exit(1)
70
+
71
+ new_tag = f"v{version}"
72
+
73
+ # Check if tag already exists
74
+ existing_tags = run_command("git tag").split("\n")
75
+ if new_tag in existing_tags:
76
+ print(f"Error: Tag '{new_tag}' already exists.")
77
+ sys.exit(1)
78
+
79
+ latest_tag = get_latest_tag()
80
+ print(f"Latest tag: {latest_tag}")
81
+
82
+ if latest_tag:
83
+ commits = get_commits_since(latest_tag)
84
+ else:
85
+ commits = get_commits_since(None)
86
+
87
+ if not commits:
88
+ print("No commits since last tag.")
89
+ sys.exit(0)
90
+
91
+ # Process commits
92
+ formatted_pr_commits = []
93
+ formatted_other_commits = []
94
+
95
+ for line in commits.split("\n"):
96
+ if not line:
97
+ continue
98
+ parts = line.split("|")
99
+ if len(parts) < 2:
100
+ continue
101
+ message = parts[0]
102
+ _author = parts[1]
103
+
104
+ # Extract PR number if present
105
+ pr_match = re.search(r"\(#(\d+)\)", message)
106
+ if pr_match:
107
+ pr_num = pr_match.group(1)
108
+ # Remove the (#num) part from message
109
+ clean_message = re.sub(r"\s*\(#\d+\)", "", message).strip()
110
+ formatted_line = f"{clean_message} in #{pr_num}"
111
+ formatted_pr_commits.append(formatted_line)
112
+ else:
113
+ formatted_line = f"{message}"
114
+ formatted_other_commits.append(formatted_line)
115
+
116
+ initial_message = f"Release {new_tag}\n\n"
117
+ if formatted_pr_commits:
118
+ initial_message += "Pull Requests:\n"
119
+ for line in formatted_pr_commits:
120
+ initial_message += f"- {line}\n"
121
+ initial_message += "\n"
122
+
123
+ if formatted_other_commits:
124
+ initial_message += "Other Changes:\n"
125
+ for line in formatted_other_commits:
126
+ initial_message += f"- {line}\n"
127
+
128
+ # Create temporary file for editing
129
+ with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tmp:
130
+ tmp.write(initial_message.encode("utf-8"))
131
+ tmp_path = tmp.name
132
+
133
+ editor = os.environ.get("EDITOR", "nano")
134
+ subprocess.call([editor, tmp_path])
135
+
136
+ with open(tmp_path, "r") as f:
137
+ tag_message = f.read().strip()
138
+
139
+ if not tag_message:
140
+ os.unlink(tmp_path)
141
+ print("Tag message is empty. Aborting.")
142
+ sys.exit(1)
143
+
144
+ print("\n--- Tag Message ---")
145
+ print(tag_message)
146
+ print("-------------------\n")
147
+
148
+ confirm = input(f"Create and push tag {new_tag}? (y/n): ")
149
+ if confirm.lower() == "y":
150
+ if dry_run:
151
+ print(f"[DRY-RUN] Would create annotated tag: {new_tag}")
152
+ print(f"[DRY-RUN] Would push tag {new_tag} to origin.")
153
+ else:
154
+ # Create annotated tag
155
+ run_command(f'git tag -a {new_tag} -F "{tmp_path}"')
156
+ print(f"Tag {new_tag} created locally.")
157
+
158
+ # Push tag
159
+ run_command(f"git push origin {new_tag}")
160
+ print(f"Tag {new_tag} pushed to origin.")
161
+ else:
162
+ print("Aborted.")
163
+
164
+ os.unlink(tmp_path)
165
+
166
+
167
+ if __name__ == "__main__":
168
+ main()
@@ -3,7 +3,7 @@
3
3
  # Workaround: use a pre-commit hook with https://github.com/scikit-image/scikit-image/blob/main/tools/generate_requirements.py
4
4
 
5
5
  # pin pylint version: it has a tendendy for stricter rules in patch updates!
6
- pylint==4.0.4
6
+ pylint==4.0.5
7
7
  flake8-docstrings
8
8
  flake8
9
9
  black
@@ -1,22 +1,22 @@
1
1
  import unittest
2
2
  from copy import deepcopy
3
3
 
4
- from ucapi.api import filter_log_msg_data
4
+ from ucapi.api import sanitize_json_message
5
5
  from ucapi.media_player import Attributes
6
6
 
7
7
 
8
- class TestFilterLogMsgData(unittest.TestCase):
8
+ class TestSanitizeJsonMessage(unittest.TestCase):
9
9
 
10
10
  def test_no_modification_when_no_msg_data(self):
11
11
  data = {}
12
- result = filter_log_msg_data(data)
12
+ result = sanitize_json_message(data)
13
13
  self.assertEqual(result, {}, "The result should be an empty dictionary")
14
14
 
15
15
  def test_no_changes_when_media_image_url_not_present(self):
16
16
  data = {"msg_data": {"attributes": {"state": "playing", "volume": 50}}}
17
17
  original = deepcopy(data)
18
18
 
19
- result = filter_log_msg_data(data)
19
+ result = sanitize_json_message(data)
20
20
 
21
21
  self.assertEqual(
22
22
  result,
@@ -36,9 +36,9 @@ class TestFilterLogMsgData(unittest.TestCase):
36
36
  expected_result = deepcopy(data)
37
37
  expected_result["msg_data"]["attributes"][
38
38
  Attributes.MEDIA_IMAGE_URL
39
- ] = "data:***"
39
+ ] = "data:..."
40
40
 
41
- result = filter_log_msg_data(data)
41
+ result = sanitize_json_message(data)
42
42
 
43
43
  self.assertEqual(
44
44
  result, expected_result, "The MEDIA_IMAGE_URL attribute should be filtered"
@@ -65,12 +65,12 @@ class TestFilterLogMsgData(unittest.TestCase):
65
65
  expected_result = deepcopy(data)
66
66
  expected_result["msg_data"][0]["attributes"][
67
67
  Attributes.MEDIA_IMAGE_URL
68
- ] = "data:***"
68
+ ] = "data:..."
69
69
  expected_result["msg_data"][1]["attributes"][
70
70
  Attributes.MEDIA_IMAGE_URL
71
- ] = "data:***"
71
+ ] = "data:..."
72
72
 
73
- result = filter_log_msg_data(data)
73
+ result = sanitize_json_message(data)
74
74
 
75
75
  self.assertEqual(
76
76
  result,
@@ -88,8 +88,48 @@ class TestFilterLogMsgData(unittest.TestCase):
88
88
  }
89
89
  original_data = deepcopy(data)
90
90
 
91
- filter_log_msg_data(data)
91
+ sanitize_json_message(data)
92
92
 
93
93
  self.assertEqual(
94
94
  data, original_data, "The input data should not be modified by the function"
95
95
  )
96
+
97
+ def test_generic_sensitive_keys_redaction(self):
98
+ sensitive_keys = [
99
+ "token",
100
+ "token_id",
101
+ "access_token",
102
+ "refresh_token",
103
+ "id_token",
104
+ "authorization_code",
105
+ "client_secret",
106
+ "secret",
107
+ "auth_url",
108
+ "client_data",
109
+ "password",
110
+ ]
111
+
112
+ for key in sensitive_keys:
113
+ msg = {key: "sensitive-value", "other": "public-value"}
114
+ sanitized = sanitize_json_message(msg)
115
+ self.assertEqual(
116
+ sanitized[key], "***REDACTED***", f"{key} should be redacted"
117
+ )
118
+ self.assertEqual(
119
+ sanitized["other"], "public-value", "public fields should remain intact"
120
+ )
121
+
122
+ def test_recursive_redaction(self):
123
+ msg = {
124
+ "level1": {
125
+ "token": "secret1",
126
+ "level2": {"secret": "secret2", "public": "data"},
127
+ },
128
+ "array": [{"refresh_token": "secret3"}, "plain-string"],
129
+ }
130
+ sanitized = sanitize_json_message(msg)
131
+ self.assertEqual(sanitized["level1"]["token"], "***REDACTED***")
132
+ self.assertEqual(sanitized["level1"]["level2"]["secret"], "***REDACTED***")
133
+ self.assertEqual(sanitized["level1"]["level2"]["public"], "data")
134
+ self.assertEqual(sanitized["array"][0]["refresh_token"], "***REDACTED***")
135
+ self.assertEqual(sanitized["array"][1], "plain-string")
@@ -0,0 +1,159 @@
1
+ """
2
+ Tests for media player entity.
3
+ """
4
+
5
+ import unittest
6
+ from ucapi.media_player import SearchMediaFilter, MediaClass, BrowseMediaItem
7
+
8
+
9
+ class TestMediaPlayer(unittest.TestCase):
10
+ """Media player tests."""
11
+
12
+ def test_search_media_filter_media_classes(self):
13
+ """Test SearchMediaFilter media_classes with standard and custom values."""
14
+ # Test with standard MediaClass
15
+ smf = SearchMediaFilter(media_classes=[MediaClass.ALBUM, MediaClass.TRACK])
16
+ self.assertEqual(smf.media_classes, [MediaClass.ALBUM, MediaClass.TRACK])
17
+
18
+ # Test with strings that match MediaClass
19
+ smf = SearchMediaFilter(media_classes=["album", "track"])
20
+ self.assertEqual(smf.media_classes, [MediaClass.ALBUM, MediaClass.TRACK])
21
+
22
+ # Test with custom string values
23
+ try:
24
+ smf = SearchMediaFilter(media_classes=["custom_class", "another_one"])
25
+ self.assertEqual(smf.media_classes, ["custom_class", "another_one"])
26
+ except ValueError as e:
27
+ self.fail(
28
+ f"SearchMediaFilter raised ValueError for custom media classes: {e}"
29
+ )
30
+
31
+ def test_search_media_filter_mixed_classes(self):
32
+ """Test SearchMediaFilter with a mix of MediaClass and custom strings."""
33
+ try:
34
+ smf = SearchMediaFilter(media_classes=[MediaClass.ALBUM, "custom_class"])
35
+ self.assertEqual(smf.media_classes, [MediaClass.ALBUM, "custom_class"])
36
+ except ValueError as e:
37
+ self.fail(
38
+ f"SearchMediaFilter raised ValueError for mixed media classes: {e}"
39
+ )
40
+
41
+ def test_search_media_filter_none(self):
42
+ """Test SearchMediaFilter with None media_classes."""
43
+ smf = SearchMediaFilter(media_classes=None)
44
+ self.assertIsNone(smf.media_classes)
45
+
46
+ def test_search_media_filter_from_dict(self):
47
+ """Test SearchMediaFilter.from_dict with custom values."""
48
+ data = {
49
+ "media_classes": ["album", "custom_class"],
50
+ "artist": "Some Artist",
51
+ "album": "Some Album",
52
+ }
53
+ try:
54
+ smf = SearchMediaFilter.from_dict(data)
55
+ self.assertEqual(smf.media_classes, [MediaClass.ALBUM, "custom_class"])
56
+ self.assertEqual(smf.artist, "Some Artist")
57
+ self.assertEqual(smf.album, "Some Album")
58
+ except ValueError as e:
59
+ self.fail(
60
+ f"SearchMediaFilter.from_dict raised ValueError for custom media classes: {e}"
61
+ )
62
+
63
+ def test_browse_media_item_validation_mandatory(self):
64
+ """Test BrowseMediaItem mandatory field validation."""
65
+ # Valid mandatory fields
66
+ item = BrowseMediaItem(media_id="id1", title="Title")
67
+ self.assertEqual(item.media_id, "id1")
68
+ self.assertEqual(item.title, "Title")
69
+
70
+ # media_id empty
71
+ with self.assertRaisesRegex(
72
+ ValueError, "media_id must be at least 1 characters"
73
+ ):
74
+ BrowseMediaItem(media_id="", title="Title")
75
+
76
+ # media_id too long
77
+ with self.assertRaisesRegex(
78
+ ValueError, "media_id must be at most 255 characters"
79
+ ):
80
+ BrowseMediaItem(media_id="a" * 256, title="Title")
81
+
82
+ # media_id wrong type
83
+ with self.assertRaisesRegex(TypeError, "media_id must be str, got int"):
84
+ BrowseMediaItem(media_id=123, title="Title")
85
+
86
+ # title empty
87
+ with self.assertRaisesRegex(ValueError, "title must be at least 1 characters"):
88
+ BrowseMediaItem(media_id="id1", title="")
89
+
90
+ # title too long
91
+ with self.assertRaisesRegex(ValueError, "title must be at most 255 characters"):
92
+ BrowseMediaItem(media_id="id1", title="a" * 256)
93
+
94
+ def test_browse_media_item_validation_optional(self):
95
+ """Test BrowseMediaItem optional field validation."""
96
+ # subtitle
97
+ with self.assertRaisesRegex(
98
+ ValueError, "subtitle must be at least 1 characters"
99
+ ):
100
+ BrowseMediaItem(media_id="id1", title="Title", subtitle="")
101
+ with self.assertRaisesRegex(
102
+ ValueError, "subtitle must be at most 255 characters"
103
+ ):
104
+ BrowseMediaItem(media_id="id1", title="Title", subtitle="a" * 256)
105
+
106
+ # artist
107
+ with self.assertRaisesRegex(ValueError, "artist must be at least 1 characters"):
108
+ BrowseMediaItem(media_id="id1", title="Title", artist="")
109
+ with self.assertRaisesRegex(
110
+ ValueError, "artist must be at most 255 characters"
111
+ ):
112
+ BrowseMediaItem(media_id="id1", title="Title", artist="a" * 256)
113
+
114
+ # album
115
+ with self.assertRaisesRegex(ValueError, "album must be at least 1 characters"):
116
+ BrowseMediaItem(media_id="id1", title="Title", album="")
117
+ with self.assertRaisesRegex(ValueError, "album must be at most 255 characters"):
118
+ BrowseMediaItem(media_id="id1", title="Title", album="a" * 256)
119
+
120
+ # media_class (only when it's a string)
121
+ # Note: media class is allowed to be empty!
122
+ BrowseMediaItem(media_id="id1", title="Title", media_class="")
123
+
124
+ with self.assertRaisesRegex(
125
+ ValueError, "media_class must be at most 255 characters"
126
+ ):
127
+ BrowseMediaItem(media_id="id1", title="Title", media_class="a" * 256)
128
+ # Verify it accepts MediaClass enum without error
129
+ BrowseMediaItem(media_id="id1", title="Title", media_class=MediaClass.ALBUM)
130
+
131
+ # media_type (only when it's a string)
132
+ # Note: media type is allowed to be empty!
133
+ BrowseMediaItem(media_id="id1", title="Title", media_type="")
134
+
135
+ with self.assertRaisesRegex(
136
+ ValueError, "media_type must be at most 255 characters"
137
+ ):
138
+ BrowseMediaItem(media_id="id1", title="Title", media_type="a" * 256)
139
+
140
+ def test_browse_media_item_validation_thumbnail(self):
141
+ """Test BrowseMediaItem thumbnail field validation."""
142
+ # Valid length
143
+ BrowseMediaItem(media_id="id1", title="Title", thumbnail="a" * 32768)
144
+
145
+ # Too short (empty)
146
+ with self.assertRaisesRegex(
147
+ ValueError, "thumbnail must be at least 1 characters"
148
+ ):
149
+ BrowseMediaItem(media_id="id1", title="Title", thumbnail="")
150
+
151
+ # Too long
152
+ with self.assertRaisesRegex(
153
+ ValueError, "thumbnail must be at most 32768 characters"
154
+ ):
155
+ BrowseMediaItem(media_id="id1", title="Title", thumbnail="a" * 32769)
156
+
157
+
158
+ if __name__ == "__main__":
159
+ unittest.main()
@@ -0,0 +1,131 @@
1
+ """Unit tests for Paging and Pagination classes."""
2
+
3
+ import json
4
+ import unittest
5
+ from dataclasses import asdict
6
+
7
+ from ucapi.api_definitions import Pagination, Paging
8
+
9
+
10
+ class TestPaging(unittest.TestCase):
11
+ """Test cases for the Paging class."""
12
+
13
+ def test_paging_default(self):
14
+ """Test default Paging instantiation."""
15
+ paging = Paging()
16
+ self.assertEqual(paging.page, 1)
17
+ self.assertEqual(paging.limit, 10)
18
+ self.assertEqual(paging.offset, 0)
19
+
20
+ def test_paging_custom(self):
21
+ """Test custom Paging instantiation."""
22
+ paging = Paging(page=2, limit=20)
23
+ self.assertEqual(paging.page, 2)
24
+ self.assertEqual(paging.limit, 20)
25
+ self.assertEqual(paging.offset, 20)
26
+
27
+ def test_paging_invalid_page(self):
28
+ """Test validation for invalid page number."""
29
+ with self.assertRaises(ValueError) as cm:
30
+ Paging(page=0)
31
+ self.assertIn("Invalid page: 0", str(cm.exception))
32
+
33
+ with self.assertRaises(ValueError):
34
+ Paging(page=-1)
35
+
36
+ def test_paging_invalid_limit(self):
37
+ """Test validation for invalid limit."""
38
+ with self.assertRaises(ValueError) as cm:
39
+ Paging(limit=0)
40
+ self.assertIn("Invalid limit: 0", str(cm.exception))
41
+
42
+ with self.assertRaises(ValueError):
43
+ Paging(limit=-1)
44
+ with self.assertRaises(ValueError):
45
+ Paging(limit=10000)
46
+
47
+ def test_paging_from_dict(self):
48
+ """Test constructing Paging from a dictionary."""
49
+ data = {"page": 3, "limit": 50}
50
+ paging = Paging.from_dict(data)
51
+ self.assertEqual(paging.page, 3)
52
+ self.assertEqual(paging.limit, 50)
53
+
54
+ def test_paging_from_dict_defaults(self):
55
+ """Test constructing Paging from an empty dictionary using defaults."""
56
+ paging = Paging.from_dict({})
57
+ self.assertEqual(paging.page, 1)
58
+ self.assertEqual(paging.limit, 10)
59
+
60
+ def test_paging_serialization(self):
61
+ """Test Paging JSON serialization."""
62
+ paging = Paging(page=2, limit=25)
63
+ serialized = asdict(paging)
64
+ self.assertEqual(serialized, {"page": 2, "limit": 25})
65
+
66
+ # Verify JSON round-trip
67
+ json_str = json.dumps(serialized)
68
+ self.assertEqual(json.loads(json_str), {"page": 2, "limit": 25})
69
+
70
+
71
+ class TestPagination(unittest.TestCase):
72
+ """Test cases for the Pagination class."""
73
+
74
+ def test_pagination_instantiation(self):
75
+ """Test Pagination instantiation with all fields."""
76
+ pagination = Pagination(page=1, limit=10, count=100)
77
+ self.assertEqual(pagination.page, 1)
78
+ self.assertEqual(pagination.limit, 10)
79
+ self.assertEqual(pagination.count, 100)
80
+
81
+ def test_pagination_no_count(self):
82
+ """Test Pagination instantiation without count."""
83
+ pagination = Pagination(page=2, limit=20)
84
+ self.assertEqual(pagination.page, 2)
85
+ self.assertEqual(pagination.limit, 20)
86
+ self.assertIsNone(pagination.count)
87
+
88
+ def test_pagination_invalid_page(self):
89
+ """Test validation for invalid page number."""
90
+ with self.assertRaises(ValueError) as cm:
91
+ Pagination(page=0, limit=10)
92
+ self.assertIn("page must be >= 1", str(cm.exception))
93
+
94
+ def test_pagination_invalid_limit(self):
95
+ """Test validation for invalid limit."""
96
+ with self.assertRaises(ValueError) as cm:
97
+ Pagination(page=1, limit=-1)
98
+ self.assertIn("Invalid limit", str(cm.exception))
99
+
100
+ def test_pagination_limit_out_of_range(self):
101
+ """Test validation for invalid limit."""
102
+ with self.assertRaises(ValueError) as cm:
103
+ Pagination(page=1, limit=10000)
104
+ self.assertIn("Invalid limit", str(cm.exception))
105
+
106
+ def test_pagination_invalid_count(self):
107
+ """Test validation for invalid count."""
108
+ with self.assertRaises(ValueError) as cm:
109
+ Pagination(page=1, limit=10, count=-1)
110
+ self.assertIn("count cannot be negative", str(cm.exception))
111
+
112
+ def test_pagination_serialization(self):
113
+ """Test Pagination JSON serialization."""
114
+ pagination = Pagination(page=1, limit=10, count=100)
115
+ serialized = asdict(pagination)
116
+ self.assertEqual(serialized, {"page": 1, "limit": 10, "count": 100})
117
+
118
+ def test_pagination_serialization_no_count(self):
119
+ """Test Pagination JSON serialization when count is None."""
120
+ pagination = Pagination(page=2, limit=20)
121
+ json_data = asdict(pagination)
122
+
123
+ # Note: In JS/TS, undefined keys are omitted during JSON.stringify.
124
+ # In Python, None becomes `null` in JSON.
125
+ # This is not an issue: the Remote core service treats `null` as "not existing".
126
+ self.assertIn("count", json_data)
127
+ self.assertIsNone(json_data["count"])
128
+
129
+
130
+ if __name__ == "__main__":
131
+ unittest.main()
@@ -19,6 +19,8 @@ from .api_definitions import ( # isort:skip # noqa: F401
19
19
  DriverSetupRequest,
20
20
  Events,
21
21
  IntegrationSetupError,
22
+ Paging,
23
+ Pagination,
22
24
  RequestUserConfirmation,
23
25
  RequestUserInput,
24
26
  SetupAction,
@@ -42,7 +44,20 @@ from .button import Button # noqa: F401
42
44
  from .climate import Climate # noqa: F401
43
45
  from .cover import Cover # noqa: F401
44
46
  from .light import Light # noqa: F401
45
- from .media_player import MediaPlayer # noqa: F401
47
+ from .media_player import ( # noqa: F401
48
+ BrowseMediaItem,
49
+ BrowseOptions,
50
+ BrowseResults,
51
+ MediaClass,
52
+ MediaContentType,
53
+ MediaPlayAction,
54
+ MediaPlayer,
55
+ RepeatMode,
56
+ SearchMediaFilter,
57
+ SearchMediaItem,
58
+ SearchOptions,
59
+ SearchResults,
60
+ )
46
61
  from .remote import Remote # noqa: F401
47
62
  from .select import Select # noqa: F401
48
63
  from .sensor import Sensor # noqa: F401
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.7.0'
22
+ __version_tuple__ = version_tuple = (0, 7, 0)
23
+
24
+ __commit_id__ = commit_id = 'gf6e6e4a0e'