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.
- {ucapi-0.5.2 → ucapi-0.7.0}/CHANGELOG.md +21 -0
- {ucapi-0.5.2/ucapi.egg-info → ucapi-0.7.0}/PKG-INFO +2 -2
- {ucapi-0.5.2 → ucapi-0.7.0}/pyproject.toml +1 -1
- ucapi-0.7.0/scripts/git-tag.py +168 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/test-requirements.txt +1 -1
- {ucapi-0.5.2 → ucapi-0.7.0}/tests/test_api.py +50 -10
- ucapi-0.7.0/tests/test_media_player.py +159 -0
- ucapi-0.7.0/tests/test_paging.py +131 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/__init__.py +16 -1
- ucapi-0.7.0/ucapi/_version.py +24 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/api.py +684 -129
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/api_definitions.py +145 -18
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/button.py +11 -4
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/climate.py +14 -7
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/cover.py +14 -7
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/entities.py +4 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/entity.py +31 -3
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/ir_emitter.py +13 -7
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/light.py +14 -7
- ucapi-0.7.0/ucapi/media_player.py +738 -0
- ucapi-0.7.0/ucapi/msg_definitions.py +68 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/remote.py +13 -6
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/select.py +13 -7
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/sensor.py +14 -7
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/switch.py +14 -7
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/ui.py +2 -2
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/voice_assistant.py +15 -8
- {ucapi-0.5.2 → ucapi-0.7.0/ucapi.egg-info}/PKG-INFO +2 -2
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi.egg-info/SOURCES.txt +4 -0
- ucapi-0.5.2/ucapi/_version.py +0 -34
- ucapi-0.5.2/ucapi/media_player.py +0 -233
- {ucapi-0.5.2 → ucapi-0.7.0}/CONTRIBUTING.md +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/LICENSE +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/README.md +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/docs/code_guidelines.md +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/docs/setup.md +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/examples/README.md +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/examples/hello_integration.json +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/examples/hello_integration.py +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/examples/remote.json +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/examples/remote.py +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/examples/remote_ui_page.json +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/examples/setup_flow.json +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/examples/setup_flow.py +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/examples/voice.json +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/examples/voice.py +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/requirements.txt +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/scripts/compile_protos.py +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/setup.cfg +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/tests/test_voice_assistant.py +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/proto/__init__.py +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/proto/ucr_integration_voice.proto +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/proto/ucr_integration_voice_pb2.py +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/proto/ucr_integration_voice_pb2.pyi +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi/voice_stream.py +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi.egg-info/dependency_links.txt +0 -0
- {ucapi-0.5.2 → ucapi-0.7.0}/ucapi.egg-info/requires.txt +0 -0
- {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.
|
|
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 ::
|
|
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 ::
|
|
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.
|
|
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
|
|
4
|
+
from ucapi.api import sanitize_json_message
|
|
5
5
|
from ucapi.media_player import Attributes
|
|
6
6
|
|
|
7
7
|
|
|
8
|
-
class
|
|
8
|
+
class TestSanitizeJsonMessage(unittest.TestCase):
|
|
9
9
|
|
|
10
10
|
def test_no_modification_when_no_msg_data(self):
|
|
11
11
|
data = {}
|
|
12
|
-
result =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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'
|