From aa5ae3fba73bd9849ae4b3cd0b9808d777748b5d Mon Sep 17 00:00:00 2001 From: Tommaso Barbugli Date: Mon, 24 Jun 2024 10:23:06 +0200 Subject: [PATCH 01/42] WIP cli --- getstream/cli/__init__.py | 52 ++++++ getstream/cli/utils.py | 21 +++ getstream/cli/video.py | 52 ++++++ getstream/models/__init__.py | 279 ++++++++++++++++++++++++++++++--- getstream/video/call.py | 5 + getstream/video/rest_client.py | 16 ++ poetry.lock | 16 +- pyproject.toml | 4 + 8 files changed, 425 insertions(+), 20 deletions(-) create mode 100644 getstream/cli/__init__.py create mode 100644 getstream/cli/utils.py create mode 100644 getstream/cli/video.py diff --git a/getstream/cli/__init__.py b/getstream/cli/__init__.py new file mode 100644 index 0000000..9182a74 --- /dev/null +++ b/getstream/cli/__init__.py @@ -0,0 +1,52 @@ +import click +from dotenv import load_dotenv + +from getstream import Stream +from getstream.cli.utils import pass_client +from getstream.cli.video import video +from getstream.stream import BASE_URL + + +@click.group() +@click.option("--api-key") +@click.option("--api-secret") +@click.option("--base-url", default=BASE_URL, show_default=True) +@click.option("--timeout", default=3.0, show_default=True) +@click.pass_context +def cli(ctx: click.Context, api_key: str, api_secret: str, base_url: str, timeout=3.0): + ctx.ensure_object(dict) + ctx.obj["client"] = Stream( + api_key=api_key, api_secret=api_secret, timeout=timeout, base_url=base_url + ) + + +@click.command() +@click.option("--user-id", required=True) +@click.option("--call-cid", multiple=True, default=None) +@click.option("--role", default=None) +@click.option("--exp-seconds", type=int, default=None) +@pass_client +def create_token( + client: Stream, user_id: str, call_cid=None, role=None, exp_seconds=None +): + if call_cid is not None and len(call_cid) > 0: + print( + client.create_call_token( + user_id=user_id, call_cids=call_cid, role=role, expiration=exp_seconds + ) + ) + else: + print(client.create_call_token(user_id=user_id)) + + +cli.add_command(create_token) +cli.add_command(video) + + +def main(): + load_dotenv() + cli(auto_envvar_prefix="STREAM", obj={}) + + +if __name__ == "__main__": + main() diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py new file mode 100644 index 0000000..11f330b --- /dev/null +++ b/getstream/cli/utils.py @@ -0,0 +1,21 @@ +from functools import update_wrapper +import click + + +def pass_client(f): + """ + Decorator that adds the Stream client to the decorated function, with this decorator you can write click commands like this + + @click.command() + @click.option("--some-option") + @pass_client + def do_something(client: Stream, some_option): + pass + + """ + + @click.pass_context + def new_func(ctx, *args, **kwargs): + return ctx.invoke(f, ctx.obj["client"], *args, **kwargs) + + return update_wrapper(new_func, f) diff --git a/getstream/cli/video.py b/getstream/cli/video.py new file mode 100644 index 0000000..030c0d7 --- /dev/null +++ b/getstream/cli/video.py @@ -0,0 +1,52 @@ +import click + +from getstream.models import CallRequest +from getstream import Stream +import uuid +from getstream.video.call import Call +from getstream.cli.utils import pass_client + + +@click.group() +def video(): + pass + + +def create_call_command(name, method): + # TODO: use reflection to create the correct command + # inspect arguments and map them to a click option + # scalar types should map to an option + # lists of scalars should map to a multiple option + # dict/object params should map to a string that we parse from json + cmd = click.command(name=name)(method) + video.add_command(cmd) + + +call_commands = { + "get_call": {"method": Call.get}, + "delete_call": {"method": Call.delete}, +} + +_ = [ + create_call_command(name, command["method"]) + for name, command in call_commands.items() +] + + +@click.command() +@click.option("--rtmp-user-id", default=f"{uuid.uuid4()}") +@pass_client +def rtmp_in_setup(client: Stream, rtmp_user_id: str): + call = client.video.call("default", f"rtmp-in-{uuid.uuid4()}").get_or_create( + data=CallRequest( + created_by_id=rtmp_user_id, + ), + ) + print(f"RTMP URL: {call.data.call.ingress.rtmp.address}") + print( + f"RTMP Stream Token: {client.create_call_token(user_id=rtmp_user_id, call_cids=[call.data.call.cid])}" + ) + print(f"React call link: https://pronto.getstream.io/join/{call.data.call.id}") + + +video.add_command(rtmp_in_setup) diff --git a/getstream/models/__init__.py b/getstream/models/__init__.py index c64ad82..d7d2998 100644 --- a/getstream/models/__init__.py +++ b/getstream/models/__init__.py @@ -519,10 +519,19 @@ class BlockUsersRequest(DataClassJsonMixin): @dataclass class BlockUsersResponse(DataClassJsonMixin): - duration: str = dc_field(metadata=dc_config(field_name="duration")) - blocks: "List[Optional[UserBlock]]" = dc_field( - metadata=dc_config(field_name="blocks") + blocked_by_user_id: str = dc_field( + metadata=dc_config(field_name="blocked_by_user_id") + ) + blocked_user_id: str = dc_field(metadata=dc_config(field_name="blocked_user_id")) + created_at: datetime = dc_field( + metadata=dc_config( + field_name="created_at", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ) ) + duration: str = dc_field(metadata=dc_config(field_name="duration")) @dataclass @@ -565,6 +574,112 @@ class BroadcastSettingsResponse(DataClassJsonMixin): hls: "HLSSettingsResponse" = dc_field(metadata=dc_config(field_name="hls")) +@dataclass +class Call(DataClassJsonMixin): + app_pk: int = dc_field(metadata=dc_config(field_name="AppPK")) + backstage: bool = dc_field(metadata=dc_config(field_name="Backstage")) + broadcast_egress: str = dc_field(metadata=dc_config(field_name="BroadcastEgress")) + cid: str = dc_field(metadata=dc_config(field_name="CID")) + created_at: datetime = dc_field( + metadata=dc_config( + field_name="CreatedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ) + ) + created_by_user_id: str = dc_field(metadata=dc_config(field_name="CreatedByUserID")) + current_session_id: str = dc_field( + metadata=dc_config(field_name="CurrentSessionID") + ) + hls_playlist_url: str = dc_field(metadata=dc_config(field_name="HLSPlaylistURL")) + id: str = dc_field(metadata=dc_config(field_name="ID")) + record_egress: str = dc_field(metadata=dc_config(field_name="RecordEgress")) + team: str = dc_field(metadata=dc_config(field_name="Team")) + thumbnail_url: str = dc_field(metadata=dc_config(field_name="ThumbnailURL")) + transcribe_egress: str = dc_field(metadata=dc_config(field_name="TranscribeEgress")) + type: str = dc_field(metadata=dc_config(field_name="Type")) + updated_at: datetime = dc_field( + metadata=dc_config( + field_name="UpdatedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ) + ) + blocked_user_ids: List[str] = dc_field( + metadata=dc_config(field_name="BlockedUserIDs") + ) + blocked_users: "List[UserObject]" = dc_field( + metadata=dc_config(field_name="BlockedUsers") + ) + members: "List[Optional[CallMember]]" = dc_field( + metadata=dc_config(field_name="Members") + ) + sfuids: List[str] = dc_field(metadata=dc_config(field_name="SFUIDs")) + custom: Dict[str, object] = dc_field(metadata=dc_config(field_name="Custom")) + deleted_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="DeletedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + egress_updated_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="EgressUpdatedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + ended_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="EndedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + last_heartbeat_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="LastHeartbeatAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + starts_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="StartsAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + call_type: "Optional[CallType]" = dc_field( + default=None, metadata=dc_config(field_name="CallType") + ) + created_by: "Optional[UserObject]" = dc_field( + default=None, metadata=dc_config(field_name="CreatedBy") + ) + session: "Optional[CallSession]" = dc_field( + default=None, metadata=dc_config(field_name="Session") + ) + settings: "Optional[CallSettings]" = dc_field( + default=None, metadata=dc_config(field_name="Settings") + ) + settings_overrides: "Optional[CallSettings]" = dc_field( + default=None, metadata=dc_config(field_name="SettingsOverrides") + ) + + @dataclass class CallEvent(DataClassJsonMixin): description: str = dc_field(metadata=dc_config(field_name="description")) @@ -579,6 +694,41 @@ class CallIngressResponse(DataClassJsonMixin): rtmp: "RTMPIngress" = dc_field(metadata=dc_config(field_name="rtmp")) +@dataclass +class CallMember(DataClassJsonMixin): + created_at: datetime = dc_field( + metadata=dc_config( + field_name="created_at", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ) + ) + role: str = dc_field(metadata=dc_config(field_name="role")) + updated_at: datetime = dc_field( + metadata=dc_config( + field_name="updated_at", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ) + ) + user_id: str = dc_field(metadata=dc_config(field_name="user_id")) + custom: Dict[str, object] = dc_field(metadata=dc_config(field_name="custom")) + deleted_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="deleted_at", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + user: "Optional[UserObject]" = dc_field( + default=None, metadata=dc_config(field_name="user") + ) + + @dataclass class CallParticipantResponse(DataClassJsonMixin): joined_at: datetime = dc_field( @@ -709,6 +859,97 @@ class CallResponse(DataClassJsonMixin): ) +@dataclass +class CallSession(DataClassJsonMixin): + app_pk: int = dc_field(metadata=dc_config(field_name="AppPK")) + call_id: str = dc_field(metadata=dc_config(field_name="CallID")) + call_type: str = dc_field(metadata=dc_config(field_name="CallType")) + created_at: datetime = dc_field( + metadata=dc_config( + field_name="CreatedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ) + ) + session_id: str = dc_field(metadata=dc_config(field_name="SessionID")) + participants: "List[UserObject]" = dc_field( + metadata=dc_config(field_name="Participants") + ) + accepted_by: "Dict[str, datetime]" = dc_field( + metadata=dc_config(field_name="AcceptedBy") + ) + missed_by: "Dict[str, datetime]" = dc_field( + metadata=dc_config(field_name="MissedBy") + ) + rejected_by: "Dict[str, datetime]" = dc_field( + metadata=dc_config(field_name="RejectedBy") + ) + deleted_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="DeletedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + ended_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="EndedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + live_ended_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="LiveEndedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + live_started_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="LiveStartedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + ring_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="RingAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + started_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="StartedAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + timer_ends_at: Optional[datetime] = dc_field( + default=None, + metadata=dc_config( + field_name="TimerEndsAt", + encoder=encode_datetime, + decoder=datetime_from_unix_ns, + mm_field=fields.DateTime(format="iso"), + ), + ) + + @dataclass class CallSessionResponse(DataClassJsonMixin): id: str = dc_field(metadata=dc_config(field_name="id")) @@ -2271,6 +2512,22 @@ class DeactivateUsersResponse(DataClassJsonMixin): task_id: str = dc_field(metadata=dc_config(field_name="task_id")) +@dataclass +class DeleteCallRequest(DataClassJsonMixin): + hard: Optional[bool] = dc_field(default=None, metadata=dc_config(field_name="hard")) + + +@dataclass +class DeleteCallResponse(DataClassJsonMixin): + duration: str = dc_field(metadata=dc_config(field_name="duration")) + task_id: Optional[str] = dc_field( + default=None, metadata=dc_config(field_name="task_id") + ) + call: "Optional[Call]" = dc_field( + default=None, metadata=dc_config(field_name="call") + ) + + @dataclass class DeleteChannelResponse(DataClassJsonMixin): duration: str = dc_field(metadata=dc_config(field_name="duration")) @@ -7721,22 +7978,6 @@ class UpsertPushProviderResponse(DataClassJsonMixin): ) -@dataclass -class UserBlock(DataClassJsonMixin): - blocked_by_user_id: str = dc_field( - metadata=dc_config(field_name="blocked_by_user_id") - ) - blocked_user_id: str = dc_field(metadata=dc_config(field_name="blocked_user_id")) - created_at: datetime = dc_field( - metadata=dc_config( - field_name="created_at", - encoder=encode_datetime, - decoder=datetime_from_unix_ns, - mm_field=fields.DateTime(format="iso"), - ) - ) - - @dataclass class UserCustomEventRequest(DataClassJsonMixin): type: str = dc_field(metadata=dc_config(field_name="type")) diff --git a/getstream/video/call.py b/getstream/video/call.py index c602d99..7dd5977 100644 --- a/getstream/video/call.py +++ b/getstream/video/call.py @@ -72,6 +72,11 @@ def block_user(self, user_id: str) -> StreamResponse[BlockUserResponse]: self._sync_from_response(response.data) return response + def delete(self, hard: Optional[bool] = None) -> StreamResponse[DeleteCallResponse]: + response = self.client.delete_call(type=self.call_type, id=self.id, hard=hard) + self._sync_from_response(response.data) + return response + def send_call_event( self, user_id: Optional[str] = None, diff --git a/getstream/video/rest_client.py b/getstream/video/rest_client.py index 372cd49..b64a1d5 100644 --- a/getstream/video/rest_client.py +++ b/getstream/video/rest_client.py @@ -148,6 +148,22 @@ def block_user( json=json, ) + def delete_call( + self, type: str, id: str, hard: Optional[bool] = None + ) -> StreamResponse[DeleteCallResponse]: + path_params = { + "type": type, + "id": id, + } + json = build_body_dict(hard=hard) + + return self.post( + "/api/v2/video/call/{type}/{id}/delete", + DeleteCallResponse, + path_params=path_params, + json=json, + ) + def send_call_event( self, type: str, diff --git a/poetry.lock b/poetry.lock index 9c4b027..014d181 100644 --- a/poetry.lock +++ b/poetry.lock @@ -44,6 +44,20 @@ files = [ {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] +[[package]] +name = "click" +version = "8.1.7" +description = "Composable command line interface toolkit" +optional = false +python-versions = ">=3.7" +files = [ + {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, + {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "platform_system == \"Windows\""} + [[package]] name = "colorama" version = "0.4.6" @@ -607,4 +621,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0.0" -content-hash = "a4506a52588ea3e61f9e094104b901834abc57b76a2e4f49d996b4032eb66822" +content-hash = "4034560cf3200269a9239613d4f37b0a15a0cbaf2f81ecf7c827440254694a79" diff --git a/pyproject.toml b/pyproject.toml index 9119841..9f3d1dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ httpx = "^0.27.0" pyjwt = "^2.8.0" dataclasses-json = "^0.6.0" marshmallow = "^3.21.0" +click = "^8.1.7" [tool.poetry.group.dev.dependencies] python-dateutil = "^2.8.2" @@ -32,3 +33,6 @@ build-backend = "poetry.core.masonry.api" [tool.ruff.lint] ignore = ["F405", "F403"] + +[tool.poetry.scripts] +cli = "getstream.cli:main" From 27db1baf8eff84c8af4b051b18c08c5f6a5b7a8b Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Tue, 25 Jun 2024 13:15:50 +0530 Subject: [PATCH 02/42] wip reflection based generated commands --- .gitignore | 1 + getstream/cli/__init__.py | 6 +- getstream/cli/__main__.py | 4 ++ getstream/cli/utils.py | 19 ++++++ getstream/cli/video.py | 133 ++++++++++++++++++++++++++++++++------ getstream/stream.py | 21 +++--- poetry.lock | 21 +++++- pyproject.toml | 1 + tests/test_cli.py | 40 ++++++++++++ 9 files changed, 211 insertions(+), 35 deletions(-) create mode 100644 getstream/cli/__main__.py create mode 100644 tests/test_cli.py diff --git a/.gitignore b/.gitignore index 57bf9b1..943d30f 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ pyvenv.cfg bin/* lib/* shell.nix +pyrightconfig.json diff --git a/getstream/cli/__init__.py b/getstream/cli/__init__.py index 9182a74..acb0f95 100644 --- a/getstream/cli/__init__.py +++ b/getstream/cli/__init__.py @@ -1,6 +1,6 @@ import click from dotenv import load_dotenv - +from typing import Optional from getstream import Stream from getstream.cli.utils import pass_client from getstream.cli.video import video @@ -27,7 +27,7 @@ def cli(ctx: click.Context, api_key: str, api_secret: str, base_url: str, timeou @click.option("--exp-seconds", type=int, default=None) @pass_client def create_token( - client: Stream, user_id: str, call_cid=None, role=None, exp_seconds=None + client: Stream, user_id: str, call_cid=None, role: Optional[str] = None, exp_seconds=None ): if call_cid is not None and len(call_cid) > 0: print( @@ -41,7 +41,7 @@ def create_token( cli.add_command(create_token) cli.add_command(video) - +#cli.add_command(chat) def main(): load_dotenv() diff --git a/getstream/cli/__main__.py b/getstream/cli/__main__.py new file mode 100644 index 0000000..868d99e --- /dev/null +++ b/getstream/cli/__main__.py @@ -0,0 +1,4 @@ +from . import main + +if __name__ == "__main__": + main() diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index 11f330b..327a74a 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -19,3 +19,22 @@ def new_func(ctx, *args, **kwargs): return ctx.invoke(f, ctx.obj["client"], *args, **kwargs) return update_wrapper(new_func, f) + +# cli/utils.py + +import json +import click +from functools import update_wrapper + +def json_option(option_name): + def decorator(f): + def callback(ctx, param, value): + if value is not None: + try: + return json.loads(value) + except json.JSONDecodeError: + raise click.BadParameter("Invalid JSON") + return value + + return click.option(option_name, callback=callback)(f) + return decorator diff --git a/getstream/cli/video.py b/getstream/cli/video.py index 030c0d7..2a40e4e 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -1,38 +1,133 @@ import click - +import inspect from getstream.models import CallRequest from getstream import Stream +from getstream.stream_response import StreamResponse import uuid from getstream.video.call import Call -from getstream.cli.utils import pass_client +from getstream.video.client import VideoClient +from getstream.cli.utils import pass_client, json_option +from pprint import pprint +import json +def print_result(result): + if isinstance(result, StreamResponse): + # TODO: verbose mode + # click.echo(f"Status Code: {result.status_code()}") + # click.echo("Headers:") + # for key, value in result.headers().items(): + # click.echo(f" {key}: {value}") + click.echo("Data:") + click.echo(json.dumps(result.data.to_dict(), indent=2, default=str)) + # rate_limits = result.rate_limit() + # if rate_limits: + # click.echo("Rate Limits:") + # click.echo(f" Limit: {rate_limits.limit}") + # click.echo(f" Remaining: {rate_limits.remaining}") + # click.echo(f" Reset: {rate_limits.reset}") + else: + click.echo(json.dumps(result, indent=2, default=str)) -@click.group() -def video(): - pass +def create_call_command(name, method): + @click.command(name=name) + @click.option('--call-type', required=True, help='The type of the call') + @click.option('--call-id', required=True, help='The ID of the call') + @pass_client + def cmd(client, call_type, call_id, **kwargs): + call = client.video.call(call_type, call_id) + result = getattr(call, name)(**kwargs) + print_result(result) + click.echo(result) + sig = inspect.signature(method) + for param_name, param in sig.parameters.items(): + if param_name in ['self', 'call_type', 'call_id']: + continue + add_option(cmd, param_name, param) -def create_call_command(name, method): - # TODO: use reflection to create the correct command - # inspect arguments and map them to a click option - # scalar types should map to an option - # lists of scalars should map to a multiple option - # dict/object params should map to a string that we parse from json - cmd = click.command(name=name)(method) - video.add_command(cmd) + return cmd + +def create_video_command(name, method): + @click.command(name=name) + @pass_client + def cmd(client, **kwargs): + result = getattr(client.video, name)(**kwargs) + print_result(result) + click.echo(result) + + sig = inspect.signature(method) + for param_name, param in sig.parameters.items(): + if param_name == 'self': + continue + add_option(cmd, param_name, param) + return cmd +def add_option(cmd, param_name, param): + if param.annotation == str: + cmd = click.option(f'--{param_name}', type=str)(cmd) + elif param.annotation == int: + cmd = click.option(f'--{param_name}', type=int)(cmd) + elif param.annotation == bool: + cmd = click.option(f'--{param_name}', is_flag=True)(cmd) + elif param.annotation == list: + cmd = click.option(f'--{param_name}', multiple=True)(cmd) + elif param.annotation == dict: + cmd = json_option(f'--{param_name}')(cmd) + else: + # print param + #print(f"Unsupported type: {param.annotation}") + cmd = click.option(f'--{param_name}')(cmd) + return cmd + +# Define the call commands call_commands = { - "get_call": {"method": Call.get}, - "delete_call": {"method": Call.delete}, + "get": {"method": Call.get}, + "update": {"method": Call.update}, + "delete": {"method": Call.delete}, + "get_or_create": {"method": Call.get_or_create}, + # Add more call commands as needed +} + +# Define the video commands +video_commands = { + "query_call_members": {"method": VideoClient.query_call_members}, + "query_call_stats": {"method": VideoClient.query_call_stats}, + "query_calls": {"method": VideoClient.query_calls}, + "list_call_types": {"method": VideoClient.list_call_types}, + "create_call_type": {"method": VideoClient.create_call_type}, + "delete_call_type": {"method": VideoClient.delete_call_type}, + "get_call_type": {"method": VideoClient.get_call_type}, + "update_call_type": {"method": VideoClient.update_call_type}, + "get_edges": {"method": VideoClient.get_edges}, + # Add more video commands as needed } -_ = [ - create_call_command(name, command["method"]) - for name, command in call_commands.items() -] +# Create the commands +call_cmds = [create_call_command(name, command["method"]) for name, command in call_commands.items()] +video_cmds = [create_video_command(name, command["method"]) for name, command in video_commands.items()] +# Create a group for call commands +@click.group() +def call(): + """Commands for specific calls""" + pass + +for cmd in call_cmds: + call.add_command(cmd) + +# Add the commands to the CLI group +@click.group() +def video(): + """Video-related commands""" + pass + +video.add_command(call) + +for cmd in video_cmds: + video.add_command(cmd) + @click.command() @click.option("--rtmp-user-id", default=f"{uuid.uuid4()}") @pass_client diff --git a/getstream/stream.py b/getstream/stream.py index ab7f74e..65fe5c0 100644 --- a/getstream/stream.py +++ b/getstream/stream.py @@ -1,8 +1,7 @@ import time import jwt from functools import cached_property -from typing import List - +from typing import List, Optional, Dict, Any from getstream.chat.client import ChatClient from getstream.common.client import CommonClient from getstream.models import UserRequest @@ -80,7 +79,7 @@ def upsert_users(self, *users: UserRequest): def create_token( self, user_id: str, - expiration: int = None, + expiration: Optional[int] = None, ): """ Generates a token for a given user, with an optional expiration time. @@ -111,9 +110,9 @@ def create_token( def create_call_token( self, user_id: str, - call_cids: List[str] = None, - role: str = None, - expiration: int = None, + call_cids: Optional[List[str]] = None, + role: Optional[str] = None, + expiration: Optional[int] = None, ): return self._create_token( user_id=user_id, call_cids=call_cids, role=role, expiration=expiration @@ -121,15 +120,15 @@ def create_call_token( def _create_token( self, - user_id: str = None, - channel_cids: List[str] = None, - call_cids: List[str] = None, - role: str = None, + user_id: Optional[str] = None, + channel_cids: Optional[List[str]] = None, + call_cids: Optional[List[str]] = None, + role: Optional[str] = None, expiration=None, ): now = int(time.time()) - claims = { + claims: Dict[str, Any] = { "iat": now, } diff --git a/poetry.lock b/poetry.lock index 014d181..412c273 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. [[package]] name = "anyio" @@ -409,6 +409,23 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} [package.extras] testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] +[[package]] +name = "pytest-mock" +version = "3.14.0" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest-mock-3.14.0.tar.gz", hash = "sha256:2719255a1efeceadbc056d6bf3df3d1c5015530fb40cf347c0f9afac88410bd0"}, + {file = "pytest_mock-3.14.0-py3-none-any.whl", hash = "sha256:0b72c38033392a5f4621342fe11e9219ac11ec9d375f8e2a0c164539e0d70f6f"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -621,4 +638,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0.0" -content-hash = "4034560cf3200269a9239613d4f37b0a15a0cbaf2f81ecf7c827440254694a79" +content-hash = "40e10402f29baab472794ed17303a5b50fc6df96a05575ad58d0fd542991136e" diff --git a/pyproject.toml b/pyproject.toml index 9f3d1dd..a972a3f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ python-dotenv = "^1.0.0" pytest = "^7.3.1" flake8 = "^6.0.0" +pytest-mock = "^3.14.0" [tool.poetry.group.dev-dependencies.dependencies] python-dotenv = "^1.0.1" ruff = "^0.4.1" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..146da74 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,40 @@ +import pytest +from click.testing import CliRunner +from getstream import Stream +from getstream.cli import cli + +@pytest.fixture +def mock_stream(mocker): + mock = mocker.Mock(spec=Stream) + mocker.patch('cli.Stream', return_value=mock) + return mock + +def test_create_token(mock_stream): + runner = CliRunner() + result = runner.invoke(cli, ['create-token', '--user-id', 'test_user']) + assert result.exit_code == 0 + mock_stream.create_call_token.assert_called_once_with(user_id='test_user') + +def test_video_get_call(mock_stream): + runner = CliRunner() + result = runner.invoke(cli, ['video', 'get-call', '--call-id', 'test_call']) + assert result.exit_code == 0 + mock_stream.video.call.assert_called_once_with('test_call') + mock_stream.video.call().get.assert_called_once() + +def test_rtmp_in_setup(mock_stream): + runner = CliRunner() + result = runner.invoke(cli, ['video', 'rtmp-in-setup']) + assert result.exit_code == 0 + mock_stream.video.call.assert_called_once() + mock_stream.video.call().get_or_create.assert_called_once() + +def test_json_input(mock_stream): + runner = CliRunner() + result = runner.invoke(cli, ['video', 'get-or-create-call', + '--call-type', 'default', + '--call-id', 'test_call', + '--data', '{"created_by_id": "test_user", "custom": {"color": "red"}}']) + assert result.exit_code == 0 + mock_stream.video.call.assert_called_once_with('default', 'test_call') + mock_stream.video.call().get_or_create.assert_called_once() From 4f3e0b6f0f502c6726632a14ced78c223105ed6f Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Tue, 25 Jun 2024 13:17:49 +0530 Subject: [PATCH 03/42] remove redudant click.echo in favour of print_result --- getstream/cli/video.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/getstream/cli/video.py b/getstream/cli/video.py index 2a40e4e..1d53981 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -37,7 +37,6 @@ def cmd(client, call_type, call_id, **kwargs): call = client.video.call(call_type, call_id) result = getattr(call, name)(**kwargs) print_result(result) - click.echo(result) sig = inspect.signature(method) for param_name, param in sig.parameters.items(): @@ -53,7 +52,6 @@ def create_video_command(name, method): def cmd(client, **kwargs): result = getattr(client.video, name)(**kwargs) print_result(result) - click.echo(result) sig = inspect.signature(method) for param_name, param in sig.parameters.items(): From 79c073f3f29bfdc636ceba655cdf216009d7455c Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Tue, 25 Jun 2024 15:12:19 +0530 Subject: [PATCH 04/42] ruff fix --- getstream/cli/utils.py | 7 +------ getstream/cli/video.py | 1 - 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index 327a74a..7ac5a0f 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -1,6 +1,7 @@ from functools import update_wrapper import click +import json def pass_client(f): """ @@ -20,12 +21,6 @@ def new_func(ctx, *args, **kwargs): return update_wrapper(new_func, f) -# cli/utils.py - -import json -import click -from functools import update_wrapper - def json_option(option_name): def decorator(f): def callback(ctx, param, value): diff --git a/getstream/cli/video.py b/getstream/cli/video.py index 1d53981..73aa801 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -7,7 +7,6 @@ from getstream.video.call import Call from getstream.video.client import VideoClient from getstream.cli.utils import pass_client, json_option -from pprint import pprint import json def print_result(result): From ca3b40353368b3acc20fe55ab4120d0e3c39d41b Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Tue, 25 Jun 2024 15:53:41 +0530 Subject: [PATCH 05/42] fix test setup --- tests/test_cli.py | 46 +++++++++++++++------------------------------- 1 file changed, 15 insertions(+), 31 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 146da74..5b91e86 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,40 +1,24 @@ import pytest from click.testing import CliRunner -from getstream import Stream -from getstream.cli import cli +from getstream import cli as stream_cli -@pytest.fixture -def mock_stream(mocker): - mock = mocker.Mock(spec=Stream) - mocker.patch('cli.Stream', return_value=mock) - return mock +def test_create_token(mocker): + # Mock the Stream client + mock_stream = mocker.Mock() + mock_stream.create_call_token.return_value = "mocked_token" -def test_create_token(mock_stream): - runner = CliRunner() - result = runner.invoke(cli, ['create-token', '--user-id', 'test_user']) - assert result.exit_code == 0 - mock_stream.create_call_token.assert_called_once_with(user_id='test_user') + # Mock the Stream class to return our mocked client + mocker.patch('getstream.cli.Stream', return_value=mock_stream) -def test_video_get_call(mock_stream): runner = CliRunner() - result = runner.invoke(cli, ['video', 'get-call', '--call-id', 'test_call']) - assert result.exit_code == 0 - mock_stream.video.call.assert_called_once_with('test_call') - mock_stream.video.call().get.assert_called_once() + result = runner.invoke(stream_cli.cli, ["create-token", "--user-id", "your_user_id"]) -def test_rtmp_in_setup(mock_stream): - runner = CliRunner() - result = runner.invoke(cli, ['video', 'rtmp-in-setup']) - assert result.exit_code == 0 - mock_stream.video.call.assert_called_once() - mock_stream.video.call().get_or_create.assert_called_once() + # Print debug information + print(f"Exit code: {result.exit_code}") + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") -def test_json_input(mock_stream): - runner = CliRunner() - result = runner.invoke(cli, ['video', 'get-or-create-call', - '--call-type', 'default', - '--call-id', 'test_call', - '--data', '{"created_by_id": "test_user", "custom": {"color": "red"}}']) + # Assertions assert result.exit_code == 0 - mock_stream.video.call.assert_called_once_with('default', 'test_call') - mock_stream.video.call().get_or_create.assert_called_once() + assert "mocked_token" in result.output + mock_stream.create_call_token.assert_called_once_with(user_id='your_user_id') From 3d9d324907cbec2afc36596822d753ceff03e761 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Tue, 25 Jun 2024 15:56:45 +0530 Subject: [PATCH 06/42] ruff fix --- tests/test_cli.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 5b91e86..a19cfef 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,3 @@ -import pytest from click.testing import CliRunner from getstream import cli as stream_cli From 853defa48335061b8d2910ad31b427f28e232d24 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Tue, 25 Jun 2024 17:13:33 +0530 Subject: [PATCH 07/42] handle --data --- getstream/cli/utils.py | 9 +++++ getstream/cli/video.py | 79 +++++++++++++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 21 deletions(-) diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index 7ac5a0f..753ac4c 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -22,6 +22,15 @@ def new_func(ctx, *args, **kwargs): return update_wrapper(new_func, f) def json_option(option_name): + """ + Decorator that adds a JSON option to the decorated function, with this decorator you can write click commands like this + + @click.command() + @json_option("--some-option") + def do_something(some_option): + pass + + """ def decorator(f): def callback(ctx, param, value): if value is not None: diff --git a/getstream/cli/video.py b/getstream/cli/video.py index 73aa801..2692c30 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -8,24 +8,41 @@ from getstream.video.client import VideoClient from getstream.cli.utils import pass_client, json_option import json - -def print_result(result): - if isinstance(result, StreamResponse): - # TODO: verbose mode - # click.echo(f"Status Code: {result.status_code()}") - # click.echo("Headers:") - # for key, value in result.headers().items(): - # click.echo(f" {key}: {value}") - click.echo("Data:") - click.echo(json.dumps(result.data.to_dict(), indent=2, default=str)) - # rate_limits = result.rate_limit() - # if rate_limits: - # click.echo("Rate Limits:") - # click.echo(f" Limit: {rate_limits.limit}") - # click.echo(f" Remaining: {rate_limits.remaining}") - # click.echo(f" Reset: {rate_limits.reset}") - else: - click.echo(json.dumps(result, indent=2, default=str)) +from typing import get_origin, get_args, Union + +def get_type_name(annotation): + """ + Get the name of a type + """ + if hasattr(annotation, '__name__'): + return annotation.__name__ + elif hasattr(annotation, '_name'): + return annotation._name + elif get_origin(annotation): + origin = get_origin(annotation) + args = get_args(annotation) + if origin is Union and type(None) in args: + # This is an Optional type + return get_type_name(args[0]) + return f"{origin.__name__}[{', '.join(get_type_name(arg) for arg in args)}]" + return str(annotation) + + +def parse_complex_type(value, annotation): + """ + Parse a complex type from a JSON string + """ + if isinstance(value, str): + try: + data_dict = json.loads(value) + type_name = get_type_name(annotation) + if type_name in globals(): + return globals()[type_name](**data_dict) + else: + return data_dict + except json.JSONDecodeError: + raise click.BadParameter(f"Invalid JSON for '{annotation}' parameter") + return value def create_call_command(name, method): @click.command(name=name) @@ -34,6 +51,15 @@ def create_call_command(name, method): @pass_client def cmd(client, call_type, call_id, **kwargs): call = client.video.call(call_type, call_id) + + # Parse complex types + sig = inspect.signature(method) + for param_name, param in sig.parameters.items(): + if param_name in kwargs: + type_name = get_type_name(param.annotation) + if type_name not in ['str', 'int', 'bool', 'list', 'dict']: + kwargs[param_name] = parse_complex_type(kwargs[param_name], param.annotation) + result = getattr(call, name)(**kwargs) print_result(result) @@ -49,6 +75,12 @@ def create_video_command(name, method): @click.command(name=name) @pass_client def cmd(client, **kwargs): + # Parse complex types + sig = inspect.signature(method) + for param_name, param in sig.parameters.items(): + if param_name in kwargs and param.annotation.__name__ not in ['str', 'int', 'bool', 'list', 'dict']: + kwargs[param_name] = parse_complex_type(kwargs[param_name], param.annotation.__name__) + result = getattr(client.video, name)(**kwargs) print_result(result) @@ -72,11 +104,16 @@ def add_option(cmd, param_name, param): elif param.annotation == dict: cmd = json_option(f'--{param_name}')(cmd) else: - # print param - #print(f"Unsupported type: {param.annotation}") - cmd = click.option(f'--{param_name}')(cmd) + cmd = json_option(f'--{param_name}')(cmd) return cmd +def print_result(result): + if isinstance(result, StreamResponse): + click.echo("Data:") + click.echo(json.dumps(result.data.to_dict(), indent=2, default=str)) + else: + click.echo(json.dumps(result, indent=2, default=str)) + # Define the call commands call_commands = { "get": {"method": Call.get}, From a435f974e9b0cf7b58740483b95d6a389ed0adee Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Tue, 25 Jun 2024 17:19:55 +0530 Subject: [PATCH 08/42] review: add better names --- getstream/cli/video.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/getstream/cli/video.py b/getstream/cli/video.py index 2692c30..af7ed4a 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -44,7 +44,7 @@ def parse_complex_type(value, annotation): raise click.BadParameter(f"Invalid JSON for '{annotation}' parameter") return value -def create_call_command(name, method): +def create_call_command_from_method(name, method): @click.command(name=name) @click.option('--call-type', required=True, help='The type of the call') @click.option('--call-id', required=True, help='The ID of the call') @@ -67,11 +67,11 @@ def cmd(client, call_type, call_id, **kwargs): for param_name, param in sig.parameters.items(): if param_name in ['self', 'call_type', 'call_id']: continue - add_option(cmd, param_name, param) + add_option_from_arg(cmd, param_name, param) return cmd -def create_video_command(name, method): +def create_command_from_method(name, method): @click.command(name=name) @pass_client def cmd(client, **kwargs): @@ -88,17 +88,18 @@ def cmd(client, **kwargs): for param_name, param in sig.parameters.items(): if param_name == 'self': continue - add_option(cmd, param_name, param) + add_option_from_arg(cmd, param_name, param) return cmd -def add_option(cmd, param_name, param): +def add_option_from_arg(cmd, param_name, param): if param.annotation == str: cmd = click.option(f'--{param_name}', type=str)(cmd) elif param.annotation == int: cmd = click.option(f'--{param_name}', type=int)(cmd) elif param.annotation == bool: cmd = click.option(f'--{param_name}', is_flag=True)(cmd) + # TODO: improve this to handle more complex types elif param.annotation == list: cmd = click.option(f'--{param_name}', multiple=True)(cmd) elif param.annotation == dict: @@ -138,8 +139,8 @@ def print_result(result): } # Create the commands -call_cmds = [create_call_command(name, command["method"]) for name, command in call_commands.items()] -video_cmds = [create_video_command(name, command["method"]) for name, command in video_commands.items()] +call_cmds = [create_call_command_from_method(name, command["method"]) for name, command in call_commands.items()] +video_cmds = [create_command_from_method(name, command["method"]) for name, command in video_commands.items()] # Create a group for call commands From a09ddce28986fc50f94ec646e0d4c1f56308e6eb Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Tue, 25 Jun 2024 17:44:11 +0530 Subject: [PATCH 09/42] more unit tests --- getstream/cli/utils.py | 55 ++++++++++++++++++++++++++++++ getstream/cli/video.py | 52 +---------------------------- tests/test_cli.py | 76 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 132 insertions(+), 51 deletions(-) diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index 753ac4c..bc1a47e 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -1,6 +1,8 @@ from functools import update_wrapper import click +from typing import get_origin, get_args, Union + import json def pass_client(f): @@ -42,3 +44,56 @@ def callback(ctx, param, value): return click.option(option_name, callback=callback)(f) return decorator + + + +def get_type_name(annotation): + """ + Get the name of a type + """ + if hasattr(annotation, '__name__'): + return annotation.__name__ + elif hasattr(annotation, '_name'): + return annotation._name + elif get_origin(annotation): + origin = get_origin(annotation) + args = get_args(annotation) + if origin is Union and type(None) in args: + # This is an Optional type + return get_type_name(args[0]) + return f"{origin.__name__}[{', '.join(get_type_name(arg) for arg in args)}]" + return str(annotation) + + +def parse_complex_type(value, annotation): + """ + Parse a complex type from a JSON string + """ + if isinstance(value, str): + try: + data_dict = json.loads(value) + type_name = get_type_name(annotation) + if type_name in globals(): + return globals()[type_name](**data_dict) + else: + return data_dict + except json.JSONDecodeError: + raise click.BadParameter(f"Invalid JSON for '{annotation}' parameter") + return value + + +def add_option_from_arg(cmd, param_name, param): + if param.annotation == str: + cmd = click.option(f'--{param_name}', type=str)(cmd) + elif param.annotation == int: + cmd = click.option(f'--{param_name}', type=int)(cmd) + elif param.annotation == bool: + cmd = click.option(f'--{param_name}', is_flag=True)(cmd) + # TODO: improve this to handle more complex types + elif param.annotation == list: + cmd = click.option(f'--{param_name}', multiple=True)(cmd) + elif param.annotation == dict: + cmd = json_option(f'--{param_name}')(cmd) + else: + cmd = json_option(f'--{param_name}')(cmd) + return cmd diff --git a/getstream/cli/video.py b/getstream/cli/video.py index af7ed4a..990857a 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -6,43 +6,8 @@ import uuid from getstream.video.call import Call from getstream.video.client import VideoClient -from getstream.cli.utils import pass_client, json_option +from getstream.cli.utils import pass_client, get_type_name, parse_complex_type, add_option_from_arg import json -from typing import get_origin, get_args, Union - -def get_type_name(annotation): - """ - Get the name of a type - """ - if hasattr(annotation, '__name__'): - return annotation.__name__ - elif hasattr(annotation, '_name'): - return annotation._name - elif get_origin(annotation): - origin = get_origin(annotation) - args = get_args(annotation) - if origin is Union and type(None) in args: - # This is an Optional type - return get_type_name(args[0]) - return f"{origin.__name__}[{', '.join(get_type_name(arg) for arg in args)}]" - return str(annotation) - - -def parse_complex_type(value, annotation): - """ - Parse a complex type from a JSON string - """ - if isinstance(value, str): - try: - data_dict = json.loads(value) - type_name = get_type_name(annotation) - if type_name in globals(): - return globals()[type_name](**data_dict) - else: - return data_dict - except json.JSONDecodeError: - raise click.BadParameter(f"Invalid JSON for '{annotation}' parameter") - return value def create_call_command_from_method(name, method): @click.command(name=name) @@ -92,21 +57,6 @@ def cmd(client, **kwargs): return cmd -def add_option_from_arg(cmd, param_name, param): - if param.annotation == str: - cmd = click.option(f'--{param_name}', type=str)(cmd) - elif param.annotation == int: - cmd = click.option(f'--{param_name}', type=int)(cmd) - elif param.annotation == bool: - cmd = click.option(f'--{param_name}', is_flag=True)(cmd) - # TODO: improve this to handle more complex types - elif param.annotation == list: - cmd = click.option(f'--{param_name}', multiple=True)(cmd) - elif param.annotation == dict: - cmd = json_option(f'--{param_name}')(cmd) - else: - cmd = json_option(f'--{param_name}')(cmd) - return cmd def print_result(result): if isinstance(result, StreamResponse): diff --git a/tests/test_cli.py b/tests/test_cli.py index a19cfef..ca82793 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,11 @@ from click.testing import CliRunner +import inspect from getstream import cli as stream_cli +import pytest +from typing import Optional, List, Dict, Union +from getstream.models import CallRequest, CallSettingsRequest +from getstream.cli.utils import get_type_name, parse_complex_type, add_option_from_arg +import click def test_create_token(mocker): # Mock the Stream client @@ -21,3 +27,73 @@ def test_create_token(mocker): assert result.exit_code == 0 assert "mocked_token" in result.output mock_stream.create_call_token.assert_called_once_with(user_id='your_user_id') + + + + +# Tests for get_type_name +def test_get_type_name(): + assert get_type_name(str) == 'str' + assert get_type_name(int) == 'int' + assert get_type_name(bool) == 'bool' + # assert get_type_name(List[str]) == 'list[str]' + # assert get_type_name(Dict[str, int]) == 'dict[str, int]' + # assert get_type_name(Optional[str]) == 'str' + # assert get_type_name(Union[str, int]) == 'Union[str, int]' + assert get_type_name(CallRequest) == 'CallRequest' + +# Tests for parse_complex_type +def test_parse_complex_type(): + # Test parsing a simple dict + assert parse_complex_type('{"key": "value"}', dict) == {"key": "value"} + + # Test parsing a CallRequest + call_request_json = '{"created_by_id": "user123", "custom": {"key": "value"}}' + parsed_call_request = parse_complex_type(call_request_json, CallRequest) + # assert isinstance(parsed_call_request, CallRequest) + # assert parsed_call_request.created_by_id == "user123" + # assert parsed_call_request.custom == {"key": "value"} + + # # Test parsing a CallSettingsRequest + # settings_json = '{"audio": {"access_request_enabled": true}, "video": {"enabled": true}}' + # parsed_settings = parse_complex_type(settings_json, CallSettingsRequest) + # assert isinstance(parsed_settings, CallSettingsRequest) + # assert parsed_settings.audio.access_request_enabled == True + # assert parsed_settings.video.enabled == True + + # Test invalid JSON + with pytest.raises(click.BadParameter): + parse_complex_type('invalid json', dict) + +# Tests for add_option +def test_add_option(): + # Create a dummy command + @click.command() + def dummy_cmd(): + pass + + # Test adding a string option + cmd = add_option_from_arg(dummy_cmd, 'string_param', inspect.Parameter('string_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=str)) + assert any(option.name == 'string_param' for option in cmd.params) + assert cmd.params[-1].type == click.STRING + + # Test adding an int option + cmd = add_option_from_arg(dummy_cmd, 'int_param', inspect.Parameter('int_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=int)) + assert any(option.name == 'int_param' for option in cmd.params) + assert cmd.params[-1].type == click.INT + + # Test adding a bool option + cmd = add_option_from_arg(dummy_cmd, 'bool_param', inspect.Parameter('bool_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=bool)) + assert any(option.name == 'bool_param' for option in cmd.params) + assert cmd.params[-1].is_flag + + # Test adding a list option + # cmd = add_option_from_arg(dummy_cmd, 'list_param', inspect.Parameter('list_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=List[str])) + # assert any(option.name == 'list_param' for option in cmd.params) + # assert cmd.params[-1].multiple + + # Test adding a complex option + cmd = add_option_from_arg(dummy_cmd, 'complex_param', inspect.Parameter('complex_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=CallRequest)) + assert any(option.name == 'complex_param' for option in cmd.params) + # Check if it's using json_option (this might need to be adjusted based on how you've implemented json_option) + assert cmd.params[-1].type == click.STRING # Assuming json_option uses STRING type From 358c13ef2e80ff315527b6cfc154850e3f0c734e Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 12:01:22 +0530 Subject: [PATCH 10/42] test get or create --- tests/test_cli.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index ca82793..c748210 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -6,6 +6,8 @@ from getstream.models import CallRequest, CallSettingsRequest from getstream.cli.utils import get_type_name, parse_complex_type, add_option_from_arg import click +import json + def test_create_token(mocker): # Mock the Stream client @@ -97,3 +99,63 @@ def dummy_cmd(): assert any(option.name == 'complex_param' for option in cmd.params) # Check if it's using json_option (this might need to be adjusted based on how you've implemented json_option) assert cmd.params[-1].type == click.STRING # Assuming json_option uses STRING type + + +def test_video_call_get_or_create(mocker): + # Mock the Stream client + mock_stream = mocker.Mock() + mock_video_client = mocker.Mock() + mock_call = mocker.Mock() + mock_stream.video = mock_video_client + mock_video_client.call.return_value = mock_call + + # Mock the get_or_create method + mock_response = mocker.Mock() + mock_response.data.to_dict.return_value = { + "call": { + "cid": "default:18632", + "created_at": "2023-07-03T12:00:00Z", + "updated_at": "2023-07-03T12:00:00Z", + "members_limit": 10, + # Add other expected fields here + } + } + mock_call.get_or_create.return_value = mock_response + + # Mock the json.dumps function to return a predictable string + mocker.patch('json.dumps', return_value='{"cid": "default:18632", "members_limit": 10, "mocked": "json"}') + + # Mock the Stream class to return our mocked client + mocker.patch('getstream.cli.Stream', return_value=mock_stream) + + # Prepare test data + json_data = '{"created_by_id": "user123", "custom": {"key": "value"}}' + + runner = CliRunner() + result = runner.invoke(stream_cli.cli, [ + "video", "call", "get_or_create", + "--call-type", "default", + "--call-id", "18632", + "--members_limit", "10", + "--data", json_data + ]) + + # Print debug information + print(f"Exit code: {result.exit_code}") + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + + # Assertions + assert result.exit_code == 0 + assert '"cid": "default:18632"' in result.output + assert '"members_limit": 10' in result.output + assert '"mocked": "json"' in result.output + + # Verify the mocked method was called with correct arguments + mock_video_client.call.assert_called_once_with("default", "18632") + mock_call.get_or_create.assert_called_once() + call_args = mock_call.get_or_create.call_args[1] + assert call_args["members_limit"] == 10 + assert isinstance(call_args["data"], dict) + assert call_args["data"]["created_by_id"] == "user123" + assert call_args["data"]["custom"] == {"key": "value"} From cdb0115b89018791ae725c96ffc17b624c39274a Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 12:18:46 +0530 Subject: [PATCH 11/42] more tests --- getstream/cli/__init__.py | 2 +- getstream/cli/utils.py | 61 +++++++++++++++++++++++---------------- tests/test_cli.py | 41 ++++++++++---------------- 3 files changed, 53 insertions(+), 51 deletions(-) diff --git a/getstream/cli/__init__.py b/getstream/cli/__init__.py index acb0f95..e9a61c5 100644 --- a/getstream/cli/__init__.py +++ b/getstream/cli/__init__.py @@ -1,6 +1,6 @@ import click from dotenv import load_dotenv -from typing import Optional +from typing import Optional, List from getstream import Stream from getstream.cli.utils import pass_client from getstream.cli.video import video diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index bc1a47e..1c6f038 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -1,7 +1,8 @@ from functools import update_wrapper import click -from typing import get_origin, get_args, Union +from typing import get_origin, get_args, List, Dict, Optional, Union + import json @@ -46,37 +47,48 @@ def callback(ctx, param, value): return decorator - def get_type_name(annotation): - """ - Get the name of a type - """ + if annotation is Optional: + return 'Optional' + if annotation is Union: + return 'Union' + + origin = get_origin(annotation) + if origin is not None: + if origin is Union: + args = get_args(annotation) + if len(args) == 2 and type(None) in args: + # This is an Optional type + other_type = next(arg for arg in args if arg is not type(None)) + return f'union[{get_type_name(other_type)}, NoneType]' + else: + args_str = ', '.join(get_type_name(arg) for arg in args) + return f'union[{args_str}]' + else: + args = get_args(annotation) + origin_name = origin.__name__.lower() + if args: + args_str = ', '.join(get_type_name(arg) for arg in args) + return f"{origin_name}[{args_str}]" + return origin_name + if hasattr(annotation, '__name__'): return annotation.__name__ - elif hasattr(annotation, '_name'): - return annotation._name - elif get_origin(annotation): - origin = get_origin(annotation) - args = get_args(annotation) - if origin is Union and type(None) in args: - # This is an Optional type - return get_type_name(args[0]) - return f"{origin.__name__}[{', '.join(get_type_name(arg) for arg in args)}]" + return str(annotation) def parse_complex_type(value, annotation): - """ - Parse a complex type from a JSON string - """ if isinstance(value, str): try: data_dict = json.loads(value) - type_name = get_type_name(annotation) - if type_name in globals(): - return globals()[type_name](**data_dict) - else: - return data_dict + if isinstance(annotation, type): # Check if annotation is a class + try: + return annotation(**data_dict) + except TypeError: + # If we can't instantiate the class, just return the dict + return data_dict + return data_dict except json.JSONDecodeError: raise click.BadParameter(f"Invalid JSON for '{annotation}' parameter") return value @@ -89,11 +101,10 @@ def add_option_from_arg(cmd, param_name, param): cmd = click.option(f'--{param_name}', type=int)(cmd) elif param.annotation == bool: cmd = click.option(f'--{param_name}', is_flag=True)(cmd) - # TODO: improve this to handle more complex types - elif param.annotation == list: + elif get_origin(param.annotation) == list: cmd = click.option(f'--{param_name}', multiple=True)(cmd) elif param.annotation == dict: cmd = json_option(f'--{param_name}')(cmd) else: cmd = json_option(f'--{param_name}')(cmd) - return cmd + return cmd \ No newline at end of file diff --git a/tests/test_cli.py b/tests/test_cli.py index c748210..7834e13 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -30,38 +30,29 @@ def test_create_token(mocker): assert "mocked_token" in result.output mock_stream.create_call_token.assert_called_once_with(user_id='your_user_id') - - - -# Tests for get_type_name def test_get_type_name(): assert get_type_name(str) == 'str' assert get_type_name(int) == 'int' assert get_type_name(bool) == 'bool' - # assert get_type_name(List[str]) == 'list[str]' - # assert get_type_name(Dict[str, int]) == 'dict[str, int]' - # assert get_type_name(Optional[str]) == 'str' - # assert get_type_name(Union[str, int]) == 'Union[str, int]' - assert get_type_name(CallRequest) == 'CallRequest' + assert get_type_name(List[str]) == 'list[str]' + assert get_type_name(Dict[str, int]) == 'dict[str, int]' + assert get_type_name(Optional[str]) == 'union[str, NoneType]' + assert get_type_name(Union[str, int]) == 'union[str, int]' -# Tests for parse_complex_type def test_parse_complex_type(): # Test parsing a simple dict assert parse_complex_type('{"key": "value"}', dict) == {"key": "value"} - # Test parsing a CallRequest - call_request_json = '{"created_by_id": "user123", "custom": {"key": "value"}}' - parsed_call_request = parse_complex_type(call_request_json, CallRequest) - # assert isinstance(parsed_call_request, CallRequest) - # assert parsed_call_request.created_by_id == "user123" - # assert parsed_call_request.custom == {"key": "value"} + class MockComplex: + def __init__(self, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) - # # Test parsing a CallSettingsRequest - # settings_json = '{"audio": {"access_request_enabled": true}, "video": {"enabled": true}}' - # parsed_settings = parse_complex_type(settings_json, CallSettingsRequest) - # assert isinstance(parsed_settings, CallSettingsRequest) - # assert parsed_settings.audio.access_request_enabled == True - # assert parsed_settings.video.enabled == True + complex_json = '{"created_by_id": "user123", "custom": {"key": "value"}}' + parsed_complex = parse_complex_type(complex_json, MockComplex) + assert isinstance(parsed_complex, MockComplex) + assert parsed_complex.created_by_id == "user123" + assert parsed_complex.custom == {"key": "value"} # Test invalid JSON with pytest.raises(click.BadParameter): @@ -90,9 +81,9 @@ def dummy_cmd(): assert cmd.params[-1].is_flag # Test adding a list option - # cmd = add_option_from_arg(dummy_cmd, 'list_param', inspect.Parameter('list_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=List[str])) - # assert any(option.name == 'list_param' for option in cmd.params) - # assert cmd.params[-1].multiple + cmd = add_option_from_arg(dummy_cmd, 'list_param', inspect.Parameter('list_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=List[str])) + assert any(option.name == 'list_param' for option in cmd.params) + assert cmd.params[-1].multiple # Test adding a complex option cmd = add_option_from_arg(dummy_cmd, 'complex_param', inspect.Parameter('complex_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=CallRequest)) From b61ec3c5559b0187b57a883428710d7b0050ed4b Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 12:25:10 +0530 Subject: [PATCH 12/42] docstrings --- getstream/cli/utils.py | 115 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 108 insertions(+), 7 deletions(-) diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index 1c6f038..3ad8f89 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -26,13 +26,33 @@ def new_func(ctx, *args, **kwargs): def json_option(option_name): """ - Decorator that adds a JSON option to the decorated function, with this decorator you can write click commands like this - - @click.command() - @json_option("--some-option") - def do_something(some_option): - pass - + Create a Click option that parses JSON input. + + This decorator creates a Click option that expects a JSON string as input. + It attempts to parse the input string as JSON and passes the resulting + Python object to the command function. + + Args: + option_name (str): The name of the option to create. + + Returns: + Callable: A decorator that adds a JSON-parsing option to a Click command. + + Raises: + click.BadParameter: If the input cannot be parsed as valid JSON. + + Examples: + >>> @click.command() + ... @json_option('--data') + ... def cmd(data): + ... click.echo(type(data)) + ... click.echo(data) + ... + >>> runner = CliRunner() + >>> result = runner.invoke(cmd, ['--data', '{"key": "value"}']) + >>> print(result.output) + + {'key': 'value'} """ def decorator(f): def callback(ctx, param, value): @@ -48,6 +68,30 @@ def callback(ctx, param, value): def get_type_name(annotation): + """ + Get a string representation of a type annotation. + + This function handles various type hints, including basic types, + List, Dict, Optional, and Union. It provides a consistent string + representation for each type, which can be useful for generating + documentation or type checking. + + Args: + annotation (Any): The type annotation to convert to a string. + + Returns: + str: A string representation of the type annotation. + + Examples: + >>> get_type_name(str) + 'str' + >>> get_type_name(List[int]) + 'list[int]' + >>> get_type_name(Optional[str]) + 'union[str, NoneType]' + >>> get_type_name(Union[str, int]) + 'union[str, int]' + """ if annotation is Optional: return 'Optional' if annotation is Union: @@ -79,6 +123,37 @@ def get_type_name(annotation): def parse_complex_type(value, annotation): + """ + Parse a complex type from a JSON string. + + This function attempts to parse a JSON string into a Python object. + If the annotation is a class, it tries to instantiate that class + with the parsed data. If that fails, it returns the parsed data as is. + + Args: + value (str): The JSON string to parse. + annotation (Type[Any]): The type annotation for the expected result. + + Returns: + Any: The parsed data, either as an instance of the annotated class + or as a basic Python data structure. + + Raises: + click.BadParameter: If the input is not valid JSON. + + Examples: + >>> parse_complex_type('{"x": 1, "y": 2}', dict) + {'x': 1, 'y': 2} + >>> class Point: + ... def __init__(self, x, y): + ... self.x = x + ... self.y = y + >>> p = parse_complex_type('{"x": 1, "y": 2}', Point) + >>> isinstance(p, Point) + True + >>> p.x, p.y + (1, 2) + """ if isinstance(value, str): try: data_dict = json.loads(value) @@ -95,6 +170,32 @@ def parse_complex_type(value, annotation): def add_option_from_arg(cmd, param_name, param): + """ + Add a Click option to a command based on a function parameter. + + This function inspects the given parameter and adds an appropriate + Click option to the command. It handles basic types (str, int, bool), + as well as more complex types like lists and dicts. + + Args: + cmd (Callable): The Click command to add the option to. + param_name (str): The name of the parameter. + param (Parameter): The inspect.Parameter object representing the function parameter. + + Returns: + Callable: The modified Click command with the new option added. + + Examples: + >>> @click.command() + ... def hello(): + ... pass + >>> param = inspect.Parameter('name', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=str) + >>> hello = add_option_from_arg(hello, 'name', param) + >>> hello.params[0].name + 'name' + >>> hello.params[0].type + + """ if param.annotation == str: cmd = click.option(f'--{param_name}', type=str)(cmd) elif param.annotation == int: From eeb5095049ee1726d73f511f81ebec52b835c707 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 12:28:25 +0530 Subject: [PATCH 13/42] more docstrings --- getstream/cli/video.py | 68 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/getstream/cli/video.py b/getstream/cli/video.py index 990857a..6ab068b 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -10,6 +10,30 @@ import json def create_call_command_from_method(name, method): + """ + Create a Click command for a call-specific method. + + This function dynamically creates a Click command based on a given call method. + It includes options for call type and ID, and inspects the method's parameters + to create corresponding Click options. + + Args: + name (str): The name of the command to create. + method (Callable): The call method to convert into a Click command. + + Returns: + click.Command: A Click command that wraps the given call method. + + Example: + >>> class Call: + ... def get(self, call_type: str, call_id: str): + ... pass + >>> cmd = create_call_command_from_method('get', Call.get) + >>> cmd.name + 'get' + >>> [p.name for p in cmd.params if isinstance(p, click.Option)] + ['call-type', 'call-id'] + """ @click.command(name=name) @click.option('--call-type', required=True, help='The type of the call') @click.option('--call-id', required=True, help='The ID of the call') @@ -37,6 +61,29 @@ def cmd(client, call_type, call_id, **kwargs): return cmd def create_command_from_method(name, method): + """ + Create a Click command from a method. + + This function dynamically creates a Click command based on a given method. + It inspects the method's parameters and creates corresponding Click options. + + Args: + name (str): The name of the command to create. + method (Callable): The method to convert into a Click command. + + Returns: + click.Command: A Click command that wraps the given method. + + Example: + >>> class VideoClient: + ... def query_calls(self, limit: int = 10): + ... pass + >>> cmd = create_command_from_method('query_calls', VideoClient.query_calls) + >>> cmd.name + 'query_calls' + >>> [p.name for p in cmd.params if isinstance(p, click.Option)] + ['limit'] + """ @click.command(name=name) @pass_client def cmd(client, **kwargs): @@ -59,6 +106,27 @@ def cmd(client, **kwargs): def print_result(result): + """ + Print the result of a command execution. + + This function handles different types of results and prints them + in a formatted manner. It specifically handles StreamResponse objects + and falls back to JSON serialization for other types. + + Args: + result (Any): The result to print. Can be a StreamResponse or any JSON-serializable object. + + Example: + >>> class MockResponse: + ... def __init__(self): + ... self.data = type('Data', (), {'to_dict': lambda: {'key': 'value'}})() + >>> mock_result = MockResponse() + >>> print_result(mock_result) + Data: + { + "key": "value" + } + """ if isinstance(result, StreamResponse): click.echo("Data:") click.echo(json.dumps(result.data.to_dict(), indent=2, default=str)) From 57170a412fb2563276460d6577c4530c2a4fe1dd Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 12:46:34 +0530 Subject: [PATCH 14/42] dash - instead of underscore _ --- getstream/cli/video.py | 24 +++++++++++++----------- tests/test_cli.py | 4 ++-- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/getstream/cli/video.py b/getstream/cli/video.py index 6ab068b..ede791d 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -49,7 +49,9 @@ def cmd(client, call_type, call_id, **kwargs): if type_name not in ['str', 'int', 'bool', 'list', 'dict']: kwargs[param_name] = parse_complex_type(kwargs[param_name], param.annotation) - result = getattr(call, name)(**kwargs) + # Convert dashes to underscores for method name + method_name = name.replace('-', '_') + result = getattr(call, method_name)(**kwargs) print_result(result) sig = inspect.signature(method) @@ -138,21 +140,21 @@ def print_result(result): "get": {"method": Call.get}, "update": {"method": Call.update}, "delete": {"method": Call.delete}, - "get_or_create": {"method": Call.get_or_create}, + "get-or-create": {"method": Call.get_or_create}, # Add more call commands as needed } # Define the video commands video_commands = { - "query_call_members": {"method": VideoClient.query_call_members}, - "query_call_stats": {"method": VideoClient.query_call_stats}, - "query_calls": {"method": VideoClient.query_calls}, - "list_call_types": {"method": VideoClient.list_call_types}, - "create_call_type": {"method": VideoClient.create_call_type}, - "delete_call_type": {"method": VideoClient.delete_call_type}, - "get_call_type": {"method": VideoClient.get_call_type}, - "update_call_type": {"method": VideoClient.update_call_type}, - "get_edges": {"method": VideoClient.get_edges}, + "query-call-members": {"method": VideoClient.query_call_members}, + "query-call-stats": {"method": VideoClient.query_call_stats}, + "query-calls": {"method": VideoClient.query_calls}, + "list-call-types": {"method": VideoClient.list_call_types}, + "create-call-type": {"method": VideoClient.create_call_type}, + "delete-call-type": {"method": VideoClient.delete_call_type}, + "get-call-type": {"method": VideoClient.get_call_type}, + "update-call-type": {"method": VideoClient.update_call_type}, + "get-edges": {"method": VideoClient.get_edges}, # Add more video commands as needed } diff --git a/tests/test_cli.py b/tests/test_cli.py index 7834e13..a4fc787 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -100,7 +100,7 @@ def test_video_call_get_or_create(mocker): mock_stream.video = mock_video_client mock_video_client.call.return_value = mock_call - # Mock the get_or_create method + # Mock the get-or-create method mock_response = mocker.Mock() mock_response.data.to_dict.return_value = { "call": { @@ -124,7 +124,7 @@ def test_video_call_get_or_create(mocker): runner = CliRunner() result = runner.invoke(stream_cli.cli, [ - "video", "call", "get_or_create", + "video", "call", "get-or-create", "--call-type", "default", "--call-id", "18632", "--members_limit", "10", From 809a2180f800b0512eb11e5073b0e440ef1fafee Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 12:55:24 +0530 Subject: [PATCH 15/42] more call commands --- getstream/cli/video.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/getstream/cli/video.py b/getstream/cli/video.py index ede791d..bdc5158 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -141,6 +141,11 @@ def print_result(result): "update": {"method": Call.update}, "delete": {"method": Call.delete}, "get-or-create": {"method": Call.get_or_create}, + "block-user": {"method": Call.block_user}, + "unblock-user": {"method": Call.unblock_user}, + "send-event": {"method": Call.send_call_event}, + "mute-users": {"method": Call.mute_users}, + "update-user-permissions": {"method": Call.update_user_permissions}, # Add more call commands as needed } From 543cfc9980caa16ae274cf5dafe26017ef555e7c Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 13:00:30 +0530 Subject: [PATCH 16/42] test_cli_create_call_with_members --- tests/test_cli.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_cli.py b/tests/test_cli.py index a4fc787..2f8f74b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -7,6 +7,7 @@ from getstream.cli.utils import get_type_name, parse_complex_type, add_option_from_arg import click import json +from tests.fixtures import mock_setup, cli_runner def test_create_token(mocker): @@ -150,3 +151,25 @@ def test_video_call_get_or_create(mocker): assert isinstance(call_args["data"], dict) assert call_args["data"]["created_by_id"] == "user123" assert call_args["data"]["custom"] == {"key": "value"} + +def test_cli_create_call_with_members(mocker): + """ + poetry run python -m getstream.cli video call get-or-create --call-type default --call-id 123456 --data '{"created_by_id": "tommaso-id", "members": [{"user_id": "thierry-id"}, {"user_id": "tommaso-id"}]}' + """ + mock_stream = mocker.Mock() + mock_video_client = mocker.Mock() + mock_call = mocker.Mock() + mock_stream.video = mock_video_client + mock_video_client.call.return_value = mock_call + mocker.patch('getstream.cli.Stream', return_value=mock_stream) + + runner = CliRunner() + result = runner.invoke(stream_cli.cli, [ + "video", "call", "get-or-create", + "--call-type", "default", + "--call-id", "123456", + "--data", '{"created_by_id": "tommaso-id", "members": [{"user_id": "thierry-id"}, {"user_id": "tommaso-id"}]}' + ]) + + assert result.exit_code == 0 + mock_call.get_or_create.assert_called_once() From 33f80ff01f83e94627228e210dbbd16fd18cf143 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 13:18:49 +0530 Subject: [PATCH 17/42] mock_setup refactor to make tests more readable --- tests/fixtures.py | 21 ++++++++++++- tests/test_cli.py | 76 +++++++++++++++++++---------------------------- 2 files changed, 51 insertions(+), 46 deletions(-) diff --git a/tests/fixtures.py b/tests/fixtures.py index c9c2501..c4f6d64 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,12 +1,31 @@ import os import uuid from typing import Dict - +from click.testing import CliRunner import pytest from getstream import Stream from getstream.models import UserRequest, FullUserResponse +def mock_setup(mocker): + mock_stream = mocker.Mock() + mock_video_client = mocker.Mock() + mock_call = mocker.Mock() + mock_stream.video = mock_video_client + mock_video_client.call.return_value = mock_call + mocker.patch('getstream.cli.Stream', return_value=mock_stream) + return mock_stream, mock_video_client, mock_call + + +@pytest.fixture +def cli_runner(): + """ + Fixture to create a CliRunner instance. + + Returns: + CliRunner: An instance of CliRunner for invoking CLI commands in tests. + """ + return CliRunner() def _client(): return Stream( diff --git a/tests/test_cli.py b/tests/test_cli.py index 2f8f74b..3d43ea3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -92,84 +92,70 @@ def dummy_cmd(): # Check if it's using json_option (this might need to be adjusted based on how you've implemented json_option) assert cmd.params[-1].type == click.STRING # Assuming json_option uses STRING type +def test_video_call_get_or_create(mocker, cli_runner): + mock_stream, mock_video_client, mock_call = mock_setup(mocker) -def test_video_call_get_or_create(mocker): - # Mock the Stream client - mock_stream = mocker.Mock() - mock_video_client = mocker.Mock() - mock_call = mocker.Mock() - mock_stream.video = mock_video_client - mock_video_client.call.return_value = mock_call - - # Mock the get-or-create method + # Mock the get_or_create method mock_response = mocker.Mock() mock_response.data.to_dict.return_value = { "call": { - "cid": "default:18632", + "cid": "default:123456", "created_at": "2023-07-03T12:00:00Z", "updated_at": "2023-07-03T12:00:00Z", "members_limit": 10, - # Add other expected fields here } } mock_call.get_or_create.return_value = mock_response # Mock the json.dumps function to return a predictable string - mocker.patch('json.dumps', return_value='{"cid": "default:18632", "members_limit": 10, "mocked": "json"}') - - # Mock the Stream class to return our mocked client - mocker.patch('getstream.cli.Stream', return_value=mock_stream) + mocker.patch('json.dumps', return_value='{"cid": "default:123456", "members_limit": 10, "mocked": "json"}') - # Prepare test data - json_data = '{"created_by_id": "user123", "custom": {"key": "value"}}' - - runner = CliRunner() - result = runner.invoke(stream_cli.cli, [ + result = cli_runner.invoke(stream_cli.cli, [ "video", "call", "get-or-create", "--call-type", "default", - "--call-id", "18632", - "--members_limit", "10", - "--data", json_data + "--call-id", "123456", + "--data", '{"created_by_id": "user-id", "members_limit": 10}' ]) - # Print debug information print(f"Exit code: {result.exit_code}") print(f"Output: {result.output}") print(f"Exception: {result.exception}") - # Assertions assert result.exit_code == 0 - assert '"cid": "default:18632"' in result.output + assert '"cid": "default:123456"' in result.output assert '"members_limit": 10' in result.output assert '"mocked": "json"' in result.output - # Verify the mocked method was called with correct arguments - mock_video_client.call.assert_called_once_with("default", "18632") + mock_video_client.call.assert_called_once_with("default", "123456") mock_call.get_or_create.assert_called_once() call_args = mock_call.get_or_create.call_args[1] - assert call_args["members_limit"] == 10 - assert isinstance(call_args["data"], dict) - assert call_args["data"]["created_by_id"] == "user123" - assert call_args["data"]["custom"] == {"key": "value"} - -def test_cli_create_call_with_members(mocker): - """ - poetry run python -m getstream.cli video call get-or-create --call-type default --call-id 123456 --data '{"created_by_id": "tommaso-id", "members": [{"user_id": "thierry-id"}, {"user_id": "tommaso-id"}]}' - """ - mock_stream = mocker.Mock() - mock_video_client = mocker.Mock() - mock_call = mocker.Mock() - mock_stream.video = mock_video_client - mock_video_client.call.return_value = mock_call - mocker.patch('getstream.cli.Stream', return_value=mock_stream) + assert 'data' in call_args + assert call_args['data']['created_by_id'] == "user-id" + assert call_args['data']['members_limit'] == 10 - runner = CliRunner() - result = runner.invoke(stream_cli.cli, [ + +def test_cli_create_call_with_members(mocker, cli_runner): + mock_stream, mock_video_client, mock_call = mock_setup(mocker) + + result = cli_runner.invoke(stream_cli.cli, [ "video", "call", "get-or-create", "--call-type", "default", "--call-id", "123456", "--data", '{"created_by_id": "tommaso-id", "members": [{"user_id": "thierry-id"}, {"user_id": "tommaso-id"}]}' ]) + print(f"Exit code: {result.exit_code}") + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + assert result.exit_code == 0 + mock_video_client.call.assert_called_once_with("default", "123456") mock_call.get_or_create.assert_called_once() + call_args = mock_call.get_or_create.call_args[1] + assert 'data' in call_args + assert call_args['data']['created_by_id'] == "tommaso-id" + assert len(call_args['data']['members']) == 2 + assert call_args['data']['members'][0]['user_id'] == "thierry-id" + assert call_args['data']['members'][1]['user_id'] == "tommaso-id" + + From 12f92e687c1947679108cbea13deb9ba87af241a Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 14:26:34 +0530 Subject: [PATCH 18/42] more tests --- getstream/cli/utils.py | 79 ++++++++++++++++++++++-------------------- getstream/cli/video.py | 51 +++++++++++++++++++++------ tests/test_cli.py | 49 +++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 49 deletions(-) diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index 3ad8f89..9a28025 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -124,48 +124,37 @@ def get_type_name(annotation): def parse_complex_type(value, annotation): """ - Parse a complex type from a JSON string. - - This function attempts to parse a JSON string into a Python object. - If the annotation is a class, it tries to instantiate that class - with the parsed data. If that fails, it returns the parsed data as is. + Parse a complex type from a JSON string or return the original value if it's not JSON. Args: - value (str): The JSON string to parse. + value (str): The input value to parse. annotation (Type[Any]): The type annotation for the expected result. Returns: Any: The parsed data, either as an instance of the annotated class - or as a basic Python data structure. + or as a basic Python data structure, or the original value if not JSON. Raises: - click.BadParameter: If the input is not valid JSON. - - Examples: - >>> parse_complex_type('{"x": 1, "y": 2}', dict) - {'x': 1, 'y': 2} - >>> class Point: - ... def __init__(self, x, y): - ... self.x = x - ... self.y = y - >>> p = parse_complex_type('{"x": 1, "y": 2}', Point) - >>> isinstance(p, Point) - True - >>> p.x, p.y - (1, 2) + click.BadParameter: If the input is invalid JSON and the annotation expects a complex type. """ + if value is None: + return None + if isinstance(value, str): try: - data_dict = json.loads(value) - if isinstance(annotation, type): # Check if annotation is a class - try: - return annotation(**data_dict) - except TypeError: - # If we can't instantiate the class, just return the dict - return data_dict - return data_dict + data = json.loads(value) except json.JSONDecodeError: - raise click.BadParameter(f"Invalid JSON for '{annotation}' parameter") + if annotation in (dict, list) or (hasattr(annotation, '__origin__') and annotation.__origin__ in (dict, list)): + raise click.BadParameter(f"Invalid JSON for '{annotation}' parameter") + return value + + if isinstance(annotation, type): # Check if annotation is a class + try: + return annotation(**data) + except TypeError: + # If we can't instantiate the class, just return the parsed data + return data + return data return value @@ -196,16 +185,30 @@ def add_option_from_arg(cmd, param_name, param): >>> hello.params[0].type """ - if param.annotation == str: + type_name = get_type_name(param.annotation) + print(f"Adding option for {param_name} with type {type_name}") + + if type_name == 'bool': + cmd = click.option(f'--{param_name}', is_flag=True, default=False)(cmd) + elif type_name == 'str': cmd = click.option(f'--{param_name}', type=str)(cmd) - elif param.annotation == int: + elif type_name == 'int': cmd = click.option(f'--{param_name}', type=int)(cmd) - elif param.annotation == bool: - cmd = click.option(f'--{param_name}', is_flag=True)(cmd) - elif get_origin(param.annotation) == list: + elif type_name.startswith('list'): cmd = click.option(f'--{param_name}', multiple=True)(cmd) - elif param.annotation == dict: + elif type_name == 'dict': cmd = json_option(f'--{param_name}')(cmd) + elif type_name.startswith('union') or type_name.startswith('Optional'): + cmd = click.option(f'--{param_name}', callback=parse_union_type)(cmd) else: - cmd = json_option(f'--{param_name}')(cmd) - return cmd \ No newline at end of file + cmd = click.option(f'--{param_name}', callback=lambda ctx, param, value: parse_complex_type(value, param.annotation))(cmd) + + return cmd + +def parse_union_type(ctx, param, value): + if value is None: + return None + try: + return json.loads(value) + except json.JSONDecodeError: + return value # If it's not valid JSON, return the original string \ No newline at end of file diff --git a/getstream/cli/video.py b/getstream/cli/video.py index bdc5158..855304e 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -41,27 +41,33 @@ def create_call_command_from_method(name, method): def cmd(client, call_type, call_id, **kwargs): call = client.video.call(call_type, call_id) - # Parse complex types + # Parse complex types and handle boolean flags sig = inspect.signature(method) + parsed_kwargs = {} for param_name, param in sig.parameters.items(): if param_name in kwargs: type_name = get_type_name(param.annotation) - if type_name not in ['str', 'int', 'bool', 'list', 'dict']: - kwargs[param_name] = parse_complex_type(kwargs[param_name], param.annotation) + if type_name == 'bool': + # For boolean flags, their presence means True + parsed_kwargs[param_name] = True + elif type_name not in ['str', 'int', 'list']: + parsed_kwargs[param_name] = parse_complex_type(kwargs[param_name], param.annotation) + else: + parsed_kwargs[param_name] = kwargs[param_name] # Convert dashes to underscores for method name method_name = name.replace('-', '_') - result = getattr(call, method_name)(**kwargs) + result = getattr(call, method_name)(**parsed_kwargs) print_result(result) sig = inspect.signature(method) for param_name, param in sig.parameters.items(): if param_name in ['self', 'call_type', 'call_id']: continue - add_option_from_arg(cmd, param_name, param) + cmd = add_option_from_arg(cmd, param_name, param) return cmd - + def create_command_from_method(name, method): """ Create a Click command from a method. @@ -164,8 +170,27 @@ def print_result(result): } # Create the commands -call_cmds = [create_call_command_from_method(name, command["method"]) for name, command in call_commands.items()] -video_cmds = [create_command_from_method(name, command["method"]) for name, command in video_commands.items()] +call_cmds = [] +for name, command in call_commands.items(): + try: + cmd = create_call_command_from_method(name, command["method"]) + if cmd is not None: + call_cmds.append(cmd) + else: + print(f"Warning: Failed to create command for {name}") + except Exception as e: + print(f"Error creating command for {name}: {str(e)}") + +video_cmds = [] +for name, command in video_commands.items(): + try: + cmd = create_command_from_method(name, command["method"]) + if cmd is not None: + video_cmds.append(cmd) + else: + print(f"Warning: Failed to create command for {name}") + except Exception as e: + print(f"Error creating command for {name}: {str(e)}") # Create a group for call commands @@ -175,7 +200,10 @@ def call(): pass for cmd in call_cmds: - call.add_command(cmd) + if cmd is not None: + call.add_command(cmd) + else: + print(f"Warning: Skipping None command") # Add the commands to the CLI group @click.group() @@ -186,7 +214,10 @@ def video(): video.add_command(call) for cmd in video_cmds: - video.add_command(cmd) + if cmd is not None: + video.add_command(cmd) + else: + print(f"Warning: Skipping None command") @click.command() @click.option("--rtmp-user-id", default=f"{uuid.uuid4()}") diff --git a/tests/test_cli.py b/tests/test_cli.py index 3d43ea3..213da6a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -44,21 +44,41 @@ def test_parse_complex_type(): # Test parsing a simple dict assert parse_complex_type('{"key": "value"}', dict) == {"key": "value"} + # Test parsing a string + assert parse_complex_type('simple string', str) == 'simple string' + + # Test parsing an integer + assert parse_complex_type('42', int) == 42 + class MockComplex: def __init__(self, **kwargs): for key, value in kwargs.items(): setattr(self, key, value) + # Test parsing a complex object complex_json = '{"created_by_id": "user123", "custom": {"key": "value"}}' parsed_complex = parse_complex_type(complex_json, MockComplex) assert isinstance(parsed_complex, MockComplex) assert parsed_complex.created_by_id == "user123" assert parsed_complex.custom == {"key": "value"} - # Test invalid JSON + # Test invalid JSON for dict annotation with pytest.raises(click.BadParameter): parse_complex_type('invalid json', dict) + # Test invalid JSON for list annotation + with pytest.raises(click.BadParameter): + parse_complex_type('invalid json', list) + + # Test invalid JSON for string annotation (should not raise an error) + assert parse_complex_type('invalid json', str) == 'invalid json' + + # Test None value + assert parse_complex_type(None, dict) is None + + # Test non-string, non-None value + assert parse_complex_type(42, int) == 42 + # Tests for add_option def test_add_option(): # Create a dummy command @@ -158,4 +178,31 @@ def test_cli_create_call_with_members(mocker, cli_runner): assert call_args['data']['members'][0]['user_id'] == "thierry-id" assert call_args['data']['members'][1]['user_id'] == "tommaso-id" +def test_cli_mute_all(mocker, cli_runner): + mock_stream, mock_video_client, mock_call = mock_setup(mocker) + result = cli_runner.invoke(stream_cli.cli, [ + "video", "call", "mute-users", + "--call-type", "default", + "--call-id", "123456", + "--muted_by_id", "user-id", + "--mute_all_users", "true", + "--audio", "true", + ]) + + print(f"Exit code: {result.exit_code}") + print(f"Output: {result.output}") + print(f"Exception: {result.exception}") + + assert result.exit_code == 0 + mock_video_client.call.assert_called_once_with("default", "123456") + mock_call.mute_users.assert_called_once_with( + muted_by_id="user-id", + mute_all_users=True, + audio=True, + screenshare=None, + screenshare_audio=None, + video=None, + user_ids=None, + muted_by=None + ) From be9adf13e4031abea5fa93871fd703323e8df88c Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 14:38:59 +0530 Subject: [PATCH 19/42] tests tests tests --- tests/test_cli.py | 59 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 213da6a..ceca6ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,4 +1,3 @@ -from click.testing import CliRunner import inspect from getstream import cli as stream_cli import pytest @@ -10,7 +9,7 @@ from tests.fixtures import mock_setup, cli_runner -def test_create_token(mocker): +def test_create_token(mocker,cli_runner): # Mock the Stream client mock_stream = mocker.Mock() mock_stream.create_call_token.return_value = "mocked_token" @@ -18,8 +17,7 @@ def test_create_token(mocker): # Mock the Stream class to return our mocked client mocker.patch('getstream.cli.Stream', return_value=mock_stream) - runner = CliRunner() - result = runner.invoke(stream_cli.cli, ["create-token", "--user-id", "your_user_id"]) + result = cli_runner.invoke(stream_cli.cli, ["create-token", "--user-id", "your_user_id"]) # Print debug information print(f"Exit code: {result.exit_code}") @@ -206,3 +204,56 @@ def test_cli_mute_all(mocker, cli_runner): user_ids=None, muted_by=None ) + +def test_cli_block_user_from_call(mocker,cli_runner): + """ + poetry run python -m getstream.cli video call block-user --call-type default --call-id 123456 --user_id bad-user-id + """ + mock_setup(mocker) + result = cli_runner.invoke(stream_cli.cli, [ + "video", "call", "block-user", + "--call-type", "default", + "--call-id", "123456", + "--user_id", "bad-user-id" + ]) + assert result.exit_code == 0 + +def test_cli_unblock_user_from_call(mocker,cli_runner): + """ + poetry run python -m getstream.cli video call unblock-user --call-type default --call-id 123456 --user_id bad-user-id + """ + mock_setup(mocker) + result = cli_runner.invoke(stream_cli.cli, [ + "video", "call", "unblock-user", + "--call-type", "default", + "--call-id", "123456", + "--user_id", "bad-user-id" + ]) + assert result.exit_code == 0 + +def test_cli_send_custom_event(mocker,cli_runner): + """ + poetry run python -m getstream.cli video call send-event --call-type default --call-id 123456 --user_id user-id --custom '{"bananas": "good"}' + """ + mock_setup(mocker) + result = cli_runner.invoke(stream_cli.cli, [ + "video", "call", "send-event", + "--call-type", "default", + "--call-id", "123456", + "--user_id", "user-id", + "--custom", '{"bananas": "good"}' + ]) + assert result.exit_code == 0 + +def test_cli_update_settings(mocker,cli_runner): + """ + poetry run python -m getstream.cli video call update --call-type default --call-id 123456 --settings_override '{"screensharing": {"enabled": true, "access_request_enabled": true}}' + """ + mock_setup(mocker) + result = cli_runner.invoke(stream_cli.cli, [ + "video", "call", "update", + "--call-type", "default", + "--call-id", "123456", + "--settings_override", '{"screensharing": {"enabled": true, "access_request_enabled": true}}' + ]) + assert result.exit_code == 0 \ No newline at end of file From 8155403ea6fa1e72104c1a9d1ecc0f3a5efd3c1c Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 17:18:20 +0530 Subject: [PATCH 20/42] ruff fix --- getstream/cli/utils.py | 2 +- getstream/cli/video.py | 4 ++-- tests/test_cli.py | 5 ++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index 9a28025..5db3c16 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -1,7 +1,7 @@ from functools import update_wrapper import click -from typing import get_origin, get_args, List, Dict, Optional, Union +from typing import get_origin, get_args, Optional, Union import json diff --git a/getstream/cli/video.py b/getstream/cli/video.py index 855304e..7b4e4e3 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -203,7 +203,7 @@ def call(): if cmd is not None: call.add_command(cmd) else: - print(f"Warning: Skipping None command") + print("Warning: Skipping None command") # Add the commands to the CLI group @click.group() @@ -217,7 +217,7 @@ def video(): if cmd is not None: video.add_command(cmd) else: - print(f"Warning: Skipping None command") + print("Warning: Skipping None command") @click.command() @click.option("--rtmp-user-id", default=f"{uuid.uuid4()}") diff --git a/tests/test_cli.py b/tests/test_cli.py index ceca6ae..2593352 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,11 +2,10 @@ from getstream import cli as stream_cli import pytest from typing import Optional, List, Dict, Union -from getstream.models import CallRequest, CallSettingsRequest +from getstream.models import CallRequest from getstream.cli.utils import get_type_name, parse_complex_type, add_option_from_arg import click -import json -from tests.fixtures import mock_setup, cli_runner +from tests.fixtures import mock_setup def test_create_token(mocker,cli_runner): From e1f5ad99fd22beffe1f2ab8d8db903cb02ff3f7a Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Wed, 3 Jul 2024 17:20:04 +0530 Subject: [PATCH 21/42] lint --- getstream/cli/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/getstream/cli/__init__.py b/getstream/cli/__init__.py index e9a61c5..acb0f95 100644 --- a/getstream/cli/__init__.py +++ b/getstream/cli/__init__.py @@ -1,6 +1,6 @@ import click from dotenv import load_dotenv -from typing import Optional, List +from typing import Optional from getstream import Stream from getstream.cli.utils import pass_client from getstream.cli.video import video From e25d9b368fc97dbd22b10ff5c38ffaa2a073799f Mon Sep 17 00:00:00 2001 From: Tommaso Barbugli Date: Wed, 3 Jul 2024 14:40:09 +0200 Subject: [PATCH 22/42] rtmp input --- getstream/cli/__init__.py | 9 +- getstream/cli/utils.py | 67 +++++---- getstream/cli/video.py | 62 +++++++-- getstream/stream.py | 2 +- tests/fixtures.py | 6 +- tests/test_cli.py | 277 ++++++++++++++++++++++++++------------ 6 files changed, 291 insertions(+), 132 deletions(-) diff --git a/getstream/cli/__init__.py b/getstream/cli/__init__.py index e9a61c5..a40c64b 100644 --- a/getstream/cli/__init__.py +++ b/getstream/cli/__init__.py @@ -27,7 +27,11 @@ def cli(ctx: click.Context, api_key: str, api_secret: str, base_url: str, timeou @click.option("--exp-seconds", type=int, default=None) @pass_client def create_token( - client: Stream, user_id: str, call_cid=None, role: Optional[str] = None, exp_seconds=None + client: Stream, + user_id: str, + call_cid=None, + role: Optional[str] = None, + exp_seconds=None, ): if call_cid is not None and len(call_cid) > 0: print( @@ -41,7 +45,8 @@ def create_token( cli.add_command(create_token) cli.add_command(video) -#cli.add_command(chat) +# cli.add_command(chat) + def main(): load_dotenv() diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index 9a28025..25b8238 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -6,6 +6,7 @@ import json + def pass_client(f): """ Decorator that adds the Stream client to the decorated function, with this decorator you can write click commands like this @@ -24,6 +25,7 @@ def new_func(ctx, *args, **kwargs): return update_wrapper(new_func, f) + def json_option(option_name): """ Create a Click option that parses JSON input. @@ -54,6 +56,7 @@ def json_option(option_name): {'key': 'value'} """ + def decorator(f): def callback(ctx, param, value): if value is not None: @@ -64,6 +67,7 @@ def callback(ctx, param, value): return value return click.option(option_name, callback=callback)(f) + return decorator @@ -93,10 +97,10 @@ def get_type_name(annotation): 'union[str, int]' """ if annotation is Optional: - return 'Optional' + return "Optional" if annotation is Union: - return 'Union' - + return "Union" + origin = get_origin(annotation) if origin is not None: if origin is Union: @@ -104,21 +108,21 @@ def get_type_name(annotation): if len(args) == 2 and type(None) in args: # This is an Optional type other_type = next(arg for arg in args if arg is not type(None)) - return f'union[{get_type_name(other_type)}, NoneType]' + return f"union[{get_type_name(other_type)}, NoneType]" else: - args_str = ', '.join(get_type_name(arg) for arg in args) - return f'union[{args_str}]' + args_str = ", ".join(get_type_name(arg) for arg in args) + return f"union[{args_str}]" else: args = get_args(annotation) origin_name = origin.__name__.lower() if args: - args_str = ', '.join(get_type_name(arg) for arg in args) + args_str = ", ".join(get_type_name(arg) for arg in args) return f"{origin_name}[{args_str}]" return origin_name - - if hasattr(annotation, '__name__'): + + if hasattr(annotation, "__name__"): return annotation.__name__ - + return str(annotation) @@ -144,7 +148,10 @@ def parse_complex_type(value, annotation): try: data = json.loads(value) except json.JSONDecodeError: - if annotation in (dict, list) or (hasattr(annotation, '__origin__') and annotation.__origin__ in (dict, list)): + if annotation in (dict, list) or ( + hasattr(annotation, "__origin__") + and annotation.__origin__ in (dict, list) + ): raise click.BadParameter(f"Invalid JSON for '{annotation}' parameter") return value @@ -187,28 +194,34 @@ def add_option_from_arg(cmd, param_name, param): """ type_name = get_type_name(param.annotation) print(f"Adding option for {param_name} with type {type_name}") - - if type_name == 'bool': - cmd = click.option(f'--{param_name}', is_flag=True, default=False)(cmd) - elif type_name == 'str': - cmd = click.option(f'--{param_name}', type=str)(cmd) - elif type_name == 'int': - cmd = click.option(f'--{param_name}', type=int)(cmd) - elif type_name.startswith('list'): - cmd = click.option(f'--{param_name}', multiple=True)(cmd) - elif type_name == 'dict': - cmd = json_option(f'--{param_name}')(cmd) - elif type_name.startswith('union') or type_name.startswith('Optional'): - cmd = click.option(f'--{param_name}', callback=parse_union_type)(cmd) + + if type_name == "bool": + cmd = click.option(f"--{param_name}", is_flag=True, default=False)(cmd) + elif type_name == "str": + cmd = click.option(f"--{param_name}", type=str)(cmd) + elif type_name == "int": + cmd = click.option(f"--{param_name}", type=int)(cmd) + elif type_name.startswith("list"): + cmd = click.option(f"--{param_name}", multiple=True)(cmd) + elif type_name == "dict": + cmd = json_option(f"--{param_name}")(cmd) + elif type_name.startswith("union") or type_name.startswith("Optional"): + cmd = click.option(f"--{param_name}", callback=parse_union_type)(cmd) else: - cmd = click.option(f'--{param_name}', callback=lambda ctx, param, value: parse_complex_type(value, param.annotation))(cmd) - + cmd = click.option( + f"--{param_name}", + callback=lambda ctx, param, value: parse_complex_type( + value, param.annotation + ), + )(cmd) + return cmd + def parse_union_type(ctx, param, value): if value is None: return None try: return json.loads(value) except json.JSONDecodeError: - return value # If it's not valid JSON, return the original string \ No newline at end of file + return value # If it's not valid JSON, return the original string diff --git a/getstream/cli/video.py b/getstream/cli/video.py index 855304e..9a73d15 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -6,9 +6,15 @@ import uuid from getstream.video.call import Call from getstream.video.client import VideoClient -from getstream.cli.utils import pass_client, get_type_name, parse_complex_type, add_option_from_arg +from getstream.cli.utils import ( + pass_client, + get_type_name, + parse_complex_type, + add_option_from_arg, +) import json + def create_call_command_from_method(name, method): """ Create a Click command for a call-specific method. @@ -34,9 +40,10 @@ def create_call_command_from_method(name, method): >>> [p.name for p in cmd.params if isinstance(p, click.Option)] ['call-type', 'call-id'] """ + @click.command(name=name) - @click.option('--call-type', required=True, help='The type of the call') - @click.option('--call-id', required=True, help='The ID of the call') + @click.option("--call-type", required=True, help="The type of the call") + @click.option("--call-id", required=True, help="The ID of the call") @pass_client def cmd(client, call_type, call_id, **kwargs): call = client.video.call(call_type, call_id) @@ -47,27 +54,30 @@ def cmd(client, call_type, call_id, **kwargs): for param_name, param in sig.parameters.items(): if param_name in kwargs: type_name = get_type_name(param.annotation) - if type_name == 'bool': + if type_name == "bool": # For boolean flags, their presence means True parsed_kwargs[param_name] = True - elif type_name not in ['str', 'int', 'list']: - parsed_kwargs[param_name] = parse_complex_type(kwargs[param_name], param.annotation) + elif type_name not in ["str", "int", "list"]: + parsed_kwargs[param_name] = parse_complex_type( + kwargs[param_name], param.annotation + ) else: parsed_kwargs[param_name] = kwargs[param_name] # Convert dashes to underscores for method name - method_name = name.replace('-', '_') + method_name = name.replace("-", "_") result = getattr(call, method_name)(**parsed_kwargs) print_result(result) sig = inspect.signature(method) for param_name, param in sig.parameters.items(): - if param_name in ['self', 'call_type', 'call_id']: + if param_name in ["self", "call_type", "call_id"]: continue cmd = add_option_from_arg(cmd, param_name, param) return cmd - + + def create_command_from_method(name, method): """ Create a Click command from a method. @@ -92,21 +102,30 @@ def create_command_from_method(name, method): >>> [p.name for p in cmd.params if isinstance(p, click.Option)] ['limit'] """ + @click.command(name=name) @pass_client def cmd(client, **kwargs): # Parse complex types sig = inspect.signature(method) for param_name, param in sig.parameters.items(): - if param_name in kwargs and param.annotation.__name__ not in ['str', 'int', 'bool', 'list', 'dict']: - kwargs[param_name] = parse_complex_type(kwargs[param_name], param.annotation.__name__) + if param_name in kwargs and param.annotation.__name__ not in [ + "str", + "int", + "bool", + "list", + "dict", + ]: + kwargs[param_name] = parse_complex_type( + kwargs[param_name], param.annotation.__name__ + ) result = getattr(client.video, name)(**kwargs) print_result(result) sig = inspect.signature(method) for param_name, param in sig.parameters.items(): - if param_name == 'self': + if param_name == "self": continue add_option_from_arg(cmd, param_name, param) @@ -141,6 +160,7 @@ def print_result(result): else: click.echo(json.dumps(result, indent=2, default=str)) + # Define the call commands call_commands = { "get": {"method": Call.get}, @@ -199,18 +219,21 @@ def call(): """Commands for specific calls""" pass + for cmd in call_cmds: if cmd is not None: call.add_command(cmd) else: print(f"Warning: Skipping None command") + # Add the commands to the CLI group @click.group() def video(): """Video-related commands""" pass + video.add_command(call) for cmd in video_cmds: @@ -219,6 +242,7 @@ def video(): else: print(f"Warning: Skipping None command") + @click.command() @click.option("--rtmp-user-id", default=f"{uuid.uuid4()}") @pass_client @@ -228,11 +252,21 @@ def rtmp_in_setup(client: Stream, rtmp_user_id: str): created_by_id=rtmp_user_id, ), ) + viewer_call_token = client.create_call_token( + user_id=f"viewer-test-{uuid.uuid4()}", call_cids=[call.data.call.cid] + ) + rtmp_call_token = client.create_call_token( + user_id=rtmp_user_id, call_cids=[call.data.call.cid] + ) print(f"RTMP URL: {call.data.call.ingress.rtmp.address}") + print(f"RTMP Stream Token: {rtmp_call_token}") print( - f"RTMP Stream Token: {client.create_call_token(user_id=rtmp_user_id, call_cids=[call.data.call.cid])}" + f"React call link: https://pronto.getstream.io/join/{call.data.call.id}?api_key={client.api_key}&token={viewer_call_token}" ) - print(f"React call link: https://pronto.getstream.io/join/{call.data.call.id}") + print(f"""FFMPEG test command: \ +ffmpeg -re -stream_loop 400 -i ./SampleVideo_1280x720_30mb.mp4 -c:v libx264 -preset veryfast -b:v 3000k \ +-maxrate 3000k -bufsize 6000k -pix_fmt yuv420p -g 50 -c:a aac -b:a 160k -ac 2 \ +-f flv {call.data.call.ingress.rtmp.address}/{rtmp_call_token}""") video.add_command(rtmp_in_setup) diff --git a/getstream/stream.py b/getstream/stream.py index 65fe5c0..85238d7 100644 --- a/getstream/stream.py +++ b/getstream/stream.py @@ -123,7 +123,7 @@ def _create_token( user_id: Optional[str] = None, channel_cids: Optional[List[str]] = None, call_cids: Optional[List[str]] = None, - role: Optional[str] = None, + role: Optional[str] = None, expiration=None, ): now = int(time.time()) diff --git a/tests/fixtures.py b/tests/fixtures.py index c4f6d64..8365e34 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -7,13 +7,14 @@ from getstream import Stream from getstream.models import UserRequest, FullUserResponse + def mock_setup(mocker): mock_stream = mocker.Mock() mock_video_client = mocker.Mock() mock_call = mocker.Mock() mock_stream.video = mock_video_client mock_video_client.call.return_value = mock_call - mocker.patch('getstream.cli.Stream', return_value=mock_stream) + mocker.patch("getstream.cli.Stream", return_value=mock_stream) return mock_stream, mock_video_client, mock_call @@ -21,12 +22,13 @@ def mock_setup(mocker): def cli_runner(): """ Fixture to create a CliRunner instance. - + Returns: CliRunner: An instance of CliRunner for invoking CLI commands in tests. """ return CliRunner() + def _client(): return Stream( api_key=os.environ.get("STREAM_API_KEY"), diff --git a/tests/test_cli.py b/tests/test_cli.py index ceca6ae..4f8c7a8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,15 +9,17 @@ from tests.fixtures import mock_setup, cli_runner -def test_create_token(mocker,cli_runner): +def test_create_token(mocker, cli_runner): # Mock the Stream client mock_stream = mocker.Mock() mock_stream.create_call_token.return_value = "mocked_token" # Mock the Stream class to return our mocked client - mocker.patch('getstream.cli.Stream', return_value=mock_stream) + mocker.patch("getstream.cli.Stream", return_value=mock_stream) - result = cli_runner.invoke(stream_cli.cli, ["create-token", "--user-id", "your_user_id"]) + result = cli_runner.invoke( + stream_cli.cli, ["create-token", "--user-id", "your_user_id"] + ) # Print debug information print(f"Exit code: {result.exit_code}") @@ -27,26 +29,28 @@ def test_create_token(mocker,cli_runner): # Assertions assert result.exit_code == 0 assert "mocked_token" in result.output - mock_stream.create_call_token.assert_called_once_with(user_id='your_user_id') + mock_stream.create_call_token.assert_called_once_with(user_id="your_user_id") + def test_get_type_name(): - assert get_type_name(str) == 'str' - assert get_type_name(int) == 'int' - assert get_type_name(bool) == 'bool' - assert get_type_name(List[str]) == 'list[str]' - assert get_type_name(Dict[str, int]) == 'dict[str, int]' - assert get_type_name(Optional[str]) == 'union[str, NoneType]' - assert get_type_name(Union[str, int]) == 'union[str, int]' + assert get_type_name(str) == "str" + assert get_type_name(int) == "int" + assert get_type_name(bool) == "bool" + assert get_type_name(List[str]) == "list[str]" + assert get_type_name(Dict[str, int]) == "dict[str, int]" + assert get_type_name(Optional[str]) == "union[str, NoneType]" + assert get_type_name(Union[str, int]) == "union[str, int]" + def test_parse_complex_type(): # Test parsing a simple dict assert parse_complex_type('{"key": "value"}', dict) == {"key": "value"} # Test parsing a string - assert parse_complex_type('simple string', str) == 'simple string' + assert parse_complex_type("simple string", str) == "simple string" # Test parsing an integer - assert parse_complex_type('42', int) == 42 + assert parse_complex_type("42", int) == 42 class MockComplex: def __init__(self, **kwargs): @@ -62,14 +66,14 @@ def __init__(self, **kwargs): # Test invalid JSON for dict annotation with pytest.raises(click.BadParameter): - parse_complex_type('invalid json', dict) + parse_complex_type("invalid json", dict) # Test invalid JSON for list annotation with pytest.raises(click.BadParameter): - parse_complex_type('invalid json', list) + parse_complex_type("invalid json", list) # Test invalid JSON for string annotation (should not raise an error) - assert parse_complex_type('invalid json', str) == 'invalid json' + assert parse_complex_type("invalid json", str) == "invalid json" # Test None value assert parse_complex_type(None, dict) is None @@ -77,6 +81,7 @@ def __init__(self, **kwargs): # Test non-string, non-None value assert parse_complex_type(42, int) == 42 + # Tests for add_option def test_add_option(): # Create a dummy command @@ -85,31 +90,64 @@ def dummy_cmd(): pass # Test adding a string option - cmd = add_option_from_arg(dummy_cmd, 'string_param', inspect.Parameter('string_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=str)) - assert any(option.name == 'string_param' for option in cmd.params) + cmd = add_option_from_arg( + dummy_cmd, + "string_param", + inspect.Parameter( + "string_param", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=str + ), + ) + assert any(option.name == "string_param" for option in cmd.params) assert cmd.params[-1].type == click.STRING # Test adding an int option - cmd = add_option_from_arg(dummy_cmd, 'int_param', inspect.Parameter('int_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=int)) - assert any(option.name == 'int_param' for option in cmd.params) + cmd = add_option_from_arg( + dummy_cmd, + "int_param", + inspect.Parameter( + "int_param", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=int + ), + ) + assert any(option.name == "int_param" for option in cmd.params) assert cmd.params[-1].type == click.INT # Test adding a bool option - cmd = add_option_from_arg(dummy_cmd, 'bool_param', inspect.Parameter('bool_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=bool)) - assert any(option.name == 'bool_param' for option in cmd.params) + cmd = add_option_from_arg( + dummy_cmd, + "bool_param", + inspect.Parameter( + "bool_param", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=bool + ), + ) + assert any(option.name == "bool_param" for option in cmd.params) assert cmd.params[-1].is_flag # Test adding a list option - cmd = add_option_from_arg(dummy_cmd, 'list_param', inspect.Parameter('list_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=List[str])) - assert any(option.name == 'list_param' for option in cmd.params) + cmd = add_option_from_arg( + dummy_cmd, + "list_param", + inspect.Parameter( + "list_param", inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=List[str] + ), + ) + assert any(option.name == "list_param" for option in cmd.params) assert cmd.params[-1].multiple # Test adding a complex option - cmd = add_option_from_arg(dummy_cmd, 'complex_param', inspect.Parameter('complex_param', inspect.Parameter.POSITIONAL_OR_KEYWORD, annotation=CallRequest)) - assert any(option.name == 'complex_param' for option in cmd.params) + cmd = add_option_from_arg( + dummy_cmd, + "complex_param", + inspect.Parameter( + "complex_param", + inspect.Parameter.POSITIONAL_OR_KEYWORD, + annotation=CallRequest, + ), + ) + assert any(option.name == "complex_param" for option in cmd.params) # Check if it's using json_option (this might need to be adjusted based on how you've implemented json_option) assert cmd.params[-1].type == click.STRING # Assuming json_option uses STRING type + def test_video_call_get_or_create(mocker, cli_runner): mock_stream, mock_video_client, mock_call = mock_setup(mocker) @@ -126,14 +164,25 @@ def test_video_call_get_or_create(mocker, cli_runner): mock_call.get_or_create.return_value = mock_response # Mock the json.dumps function to return a predictable string - mocker.patch('json.dumps', return_value='{"cid": "default:123456", "members_limit": 10, "mocked": "json"}') + mocker.patch( + "json.dumps", + return_value='{"cid": "default:123456", "members_limit": 10, "mocked": "json"}', + ) - result = cli_runner.invoke(stream_cli.cli, [ - "video", "call", "get-or-create", - "--call-type", "default", - "--call-id", "123456", - "--data", '{"created_by_id": "user-id", "members_limit": 10}' - ]) + result = cli_runner.invoke( + stream_cli.cli, + [ + "video", + "call", + "get-or-create", + "--call-type", + "default", + "--call-id", + "123456", + "--data", + '{"created_by_id": "user-id", "members_limit": 10}', + ], + ) print(f"Exit code: {result.exit_code}") print(f"Output: {result.output}") @@ -147,20 +196,28 @@ def test_video_call_get_or_create(mocker, cli_runner): mock_video_client.call.assert_called_once_with("default", "123456") mock_call.get_or_create.assert_called_once() call_args = mock_call.get_or_create.call_args[1] - assert 'data' in call_args - assert call_args['data']['created_by_id'] == "user-id" - assert call_args['data']['members_limit'] == 10 + assert "data" in call_args + assert call_args["data"]["created_by_id"] == "user-id" + assert call_args["data"]["members_limit"] == 10 def test_cli_create_call_with_members(mocker, cli_runner): mock_stream, mock_video_client, mock_call = mock_setup(mocker) - result = cli_runner.invoke(stream_cli.cli, [ - "video", "call", "get-or-create", - "--call-type", "default", - "--call-id", "123456", - "--data", '{"created_by_id": "tommaso-id", "members": [{"user_id": "thierry-id"}, {"user_id": "tommaso-id"}]}' - ]) + result = cli_runner.invoke( + stream_cli.cli, + [ + "video", + "call", + "get-or-create", + "--call-type", + "default", + "--call-id", + "123456", + "--data", + '{"created_by_id": "tommaso-id", "members": [{"user_id": "thierry-id"}, {"user_id": "tommaso-id"}]}', + ], + ) print(f"Exit code: {result.exit_code}") print(f"Output: {result.output}") @@ -170,23 +227,34 @@ def test_cli_create_call_with_members(mocker, cli_runner): mock_video_client.call.assert_called_once_with("default", "123456") mock_call.get_or_create.assert_called_once() call_args = mock_call.get_or_create.call_args[1] - assert 'data' in call_args - assert call_args['data']['created_by_id'] == "tommaso-id" - assert len(call_args['data']['members']) == 2 - assert call_args['data']['members'][0]['user_id'] == "thierry-id" - assert call_args['data']['members'][1]['user_id'] == "tommaso-id" + assert "data" in call_args + assert call_args["data"]["created_by_id"] == "tommaso-id" + assert len(call_args["data"]["members"]) == 2 + assert call_args["data"]["members"][0]["user_id"] == "thierry-id" + assert call_args["data"]["members"][1]["user_id"] == "tommaso-id" + def test_cli_mute_all(mocker, cli_runner): mock_stream, mock_video_client, mock_call = mock_setup(mocker) - result = cli_runner.invoke(stream_cli.cli, [ - "video", "call", "mute-users", - "--call-type", "default", - "--call-id", "123456", - "--muted_by_id", "user-id", - "--mute_all_users", "true", - "--audio", "true", - ]) + result = cli_runner.invoke( + stream_cli.cli, + [ + "video", + "call", + "mute-users", + "--call-type", + "default", + "--call-id", + "123456", + "--muted_by_id", + "user-id", + "--mute_all_users", + "true", + "--audio", + "true", + ], + ) print(f"Exit code: {result.exit_code}") print(f"Output: {result.output}") @@ -202,58 +270,95 @@ def test_cli_mute_all(mocker, cli_runner): screenshare_audio=None, video=None, user_ids=None, - muted_by=None + muted_by=None, ) -def test_cli_block_user_from_call(mocker,cli_runner): + +def test_cli_block_user_from_call(mocker, cli_runner): """ poetry run python -m getstream.cli video call block-user --call-type default --call-id 123456 --user_id bad-user-id """ mock_setup(mocker) - result = cli_runner.invoke(stream_cli.cli, [ - "video", "call", "block-user", - "--call-type", "default", - "--call-id", "123456", - "--user_id", "bad-user-id" - ]) + result = cli_runner.invoke( + stream_cli.cli, + [ + "video", + "call", + "block-user", + "--call-type", + "default", + "--call-id", + "123456", + "--user_id", + "bad-user-id", + ], + ) assert result.exit_code == 0 -def test_cli_unblock_user_from_call(mocker,cli_runner): + +def test_cli_unblock_user_from_call(mocker, cli_runner): """ poetry run python -m getstream.cli video call unblock-user --call-type default --call-id 123456 --user_id bad-user-id """ mock_setup(mocker) - result = cli_runner.invoke(stream_cli.cli, [ - "video", "call", "unblock-user", - "--call-type", "default", - "--call-id", "123456", - "--user_id", "bad-user-id" - ]) + result = cli_runner.invoke( + stream_cli.cli, + [ + "video", + "call", + "unblock-user", + "--call-type", + "default", + "--call-id", + "123456", + "--user_id", + "bad-user-id", + ], + ) assert result.exit_code == 0 -def test_cli_send_custom_event(mocker,cli_runner): + +def test_cli_send_custom_event(mocker, cli_runner): """ poetry run python -m getstream.cli video call send-event --call-type default --call-id 123456 --user_id user-id --custom '{"bananas": "good"}' """ mock_setup(mocker) - result = cli_runner.invoke(stream_cli.cli, [ - "video", "call", "send-event", - "--call-type", "default", - "--call-id", "123456", - "--user_id", "user-id", - "--custom", '{"bananas": "good"}' - ]) + result = cli_runner.invoke( + stream_cli.cli, + [ + "video", + "call", + "send-event", + "--call-type", + "default", + "--call-id", + "123456", + "--user_id", + "user-id", + "--custom", + '{"bananas": "good"}', + ], + ) assert result.exit_code == 0 -def test_cli_update_settings(mocker,cli_runner): + +def test_cli_update_settings(mocker, cli_runner): """ poetry run python -m getstream.cli video call update --call-type default --call-id 123456 --settings_override '{"screensharing": {"enabled": true, "access_request_enabled": true}}' """ mock_setup(mocker) - result = cli_runner.invoke(stream_cli.cli, [ - "video", "call", "update", - "--call-type", "default", - "--call-id", "123456", - "--settings_override", '{"screensharing": {"enabled": true, "access_request_enabled": true}}' - ]) - assert result.exit_code == 0 \ No newline at end of file + result = cli_runner.invoke( + stream_cli.cli, + [ + "video", + "call", + "update", + "--call-type", + "default", + "--call-id", + "123456", + "--settings_override", + '{"screensharing": {"enabled": true, "access_request_enabled": true}}', + ], + ) + assert result.exit_code == 0 From 683314c58f0f29ce5a4443d206e53ee0592f50cd Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Thu, 4 Jul 2024 13:07:23 +0530 Subject: [PATCH 23/42] fix send-call-event cli --- getstream/cli/video.py | 2 +- tests/test_cli.py | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/getstream/cli/video.py b/getstream/cli/video.py index dbd1061..fc83f1a 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -169,7 +169,7 @@ def print_result(result): "get-or-create": {"method": Call.get_or_create}, "block-user": {"method": Call.block_user}, "unblock-user": {"method": Call.unblock_user}, - "send-event": {"method": Call.send_call_event}, + "send-call-event": {"method": Call.send_call_event}, "mute-users": {"method": Call.mute_users}, "update-user-permissions": {"method": Call.update_user_permissions}, # Add more call commands as needed diff --git a/tests/test_cli.py b/tests/test_cli.py index e3da20b..eb7a846 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,7 +5,7 @@ from getstream.models import CallRequest from getstream.cli.utils import get_type_name, parse_complex_type, add_option_from_arg import click -from tests.fixtures import mock_setup +from tests.fixtures import mock_setup, cli_runner def test_create_token(mocker, cli_runner): @@ -319,7 +319,7 @@ def test_cli_unblock_user_from_call(mocker, cli_runner): def test_cli_send_custom_event(mocker, cli_runner): """ - poetry run python -m getstream.cli video call send-event --call-type default --call-id 123456 --user_id user-id --custom '{"bananas": "good"}' + poetry run python -m getstream.cli video call send-call-event --call-type default --call-id 123456 --user_id user-id --custom '{"bananas": "good"}' """ mock_setup(mocker) result = cli_runner.invoke( @@ -327,7 +327,7 @@ def test_cli_send_custom_event(mocker, cli_runner): [ "video", "call", - "send-event", + "send-call-event", "--call-type", "default", "--call-id", From 9fd9e6a9803c52c955d1a99d375a5ab739f18d03 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Thu, 4 Jul 2024 13:07:35 +0530 Subject: [PATCH 24/42] makefile wip --- Makefile | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3a4eca0 --- /dev/null +++ b/Makefile @@ -0,0 +1,65 @@ +# Python interpreter +PYTHON := python3 + +# Poetry +POETRY := poetry + +# Virtual environment +VENV := .venv + +# Source directory +SRC_DIR := getstream + +# Test directory +TEST_DIR := tests + +# Default target +.DEFAULT_GOAL := help + +.PHONY: help +help: + @echo "Available commands:" + @echo " install : Install project dependencies" + @echo " update : Update project dependencies" + @echo " test : Run tests" + @echo " lint : Run linter" + @echo " fix : Auto-fix linter issues" + @echo " format : Format code" + @echo " clean : Remove build artifacts and cache files" + @echo " run : Run the CLI application" + +.PHONY: install +install: + $(POETRY) install + +.PHONY: update +update: + $(POETRY) update + +.PHONY: test +test: + $(POETRY) run pytest $(TEST_DIR) + +.PHONY: lint +lint: + $(POETRY) run ruff check $(SRC_DIR) $(TEST_DIR) + +.PHONY: fix +fix: + $(POETRY) run ruff check --fix $(SRC_DIR) $(TEST_DIR) + +.PHONY: format +format: + $(POETRY) run ruff format $(SRC_DIR) $(TEST_DIR) + +.PHONY: clean +clean: + find . -type d -name "__pycache__" -exec rm -rf {} + + find . -type f -name "*.py[co]" -delete + find . -type d -name ".pytest_cache" -exec rm -rf {} + + find . -type d -name ".ruff_cache" -exec rm -rf {} + + rm -rf build dist *.egg-info + +.PHONY: run +run: + $(POETRY) run cli \ No newline at end of file From 85219e86c633ef8cddae2f8791ef60e00e8ce6c5 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Thu, 4 Jul 2024 13:14:15 +0530 Subject: [PATCH 25/42] makefile --- Makefile | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 3a4eca0..47e678e 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,9 @@ PYTHON := python3 # Poetry POETRY := poetry +# pipx +PIPX := pipx + # Virtual environment VENV := .venv @@ -13,6 +16,9 @@ SRC_DIR := getstream # Test directory TEST_DIR := tests +# Project name (assuming it's the same as the directory name) +PROJECT_NAME := getstream + # Default target .DEFAULT_GOAL := help @@ -27,6 +33,10 @@ help: @echo " format : Format code" @echo " clean : Remove build artifacts and cache files" @echo " run : Run the CLI application" + @echo " pipx-install: Install the project globally using pipx" + @echo " pipx-uninstall: Uninstall the project from pipx" + @echo " build : Build the project" + @echo " publish : Publish the project to PyPI" .PHONY: install install: @@ -62,4 +72,20 @@ clean: .PHONY: run run: - $(POETRY) run cli \ No newline at end of file + $(POETRY) run cli + +.PHONY: pipx-install +pipx-install: + $(PIPX) install --editable . + +.PHONY: pipx-uninstall +pipx-uninstall: + $(PIPX) uninstall $(PROJECT_NAME) + +.PHONY: build +build: + $(POETRY) build + +.PHONY: publish +publish: + $(POETRY) publish \ No newline at end of file From 4062bdcab3ef636360a857d7d9ffd10b21be6134 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Thu, 4 Jul 2024 13:56:13 +0530 Subject: [PATCH 26/42] Dockerfile + makefile commands --- Dockerfile | 25 +++++++++++++++++++++++++ Makefile | 24 +++++++++++++++++++++++- entrypoint.sh | 10 ++++++++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 Dockerfile create mode 100755 entrypoint.sh diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b09ecf6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +# Use an official Python runtime as a parent image +FROM python:3.9-slim + +# Set the working directory in the container +WORKDIR /app + +# Copy the current directory contents into the container at /app +COPY . /app + +# Install poetry +RUN pip install poetry + +# Install project dependencies +RUN poetry config virtualenvs.create false \ + && poetry install --no-interaction --no-ansi + +# Copy the entrypoint script into the container +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Set the entrypoint script to be executed +ENTRYPOINT ["/entrypoint.sh"] + +# Default command, if no command is provided when running the container +CMD ["--help"] \ No newline at end of file diff --git a/Makefile b/Makefile index 47e678e..5374c94 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,12 @@ PROJECT_NAME := getstream # Default target .DEFAULT_GOAL := help +# Docker image name +IMAGE_NAME := getstream-cli + +# GitHub Container Registry +GHCR_REPO := ghcr.io/$(shell echo ${GITHUB_REPOSITORY} | tr '[:upper:]' '[:lower:]') + .PHONY: help help: @echo "Available commands:" @@ -37,6 +43,9 @@ help: @echo " pipx-uninstall: Uninstall the project from pipx" @echo " build : Build the project" @echo " publish : Publish the project to PyPI" + @echo " docker-build : Build Docker image" + @echo " docker-run : Run Docker container (use CMD='command' to specify CLI command)" + @echo " docker-push : Push Docker image to GitHub Container Registry" .PHONY: install install: @@ -88,4 +97,17 @@ build: .PHONY: publish publish: - $(POETRY) publish \ No newline at end of file + $(POETRY) publish + +.PHONY: docker-build +docker-build: + docker build -t $(IMAGE_NAME) . + +.PHONY: docker-run +docker-run: + docker run -e STREAM_API_KEY=$(STREAM_API_KEY) -e STREAM_API_SECRET=$(STREAM_API_SECRET) $(IMAGE_NAME) $(CMD) + +.PHONY: docker-push +docker-push: + docker tag $(IMAGE_NAME) $(GHCR_REPO):$(VERSION) + docker push $(GHCR_REPO):$(VERSION) \ No newline at end of file diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..361bff1 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Check if required environment variables are set +if [[ -z "${STREAM_API_KEY}" || -z "${STREAM_API_SECRET}" ]]; then + echo "Error: STREAM_API_KEY and STREAM_API_SECRET must be set" + exit 1 +fi + +# Run the CLI command with all passed arguments +poetry run python -m getstream.cli "$@" \ No newline at end of file From c29658457bdb7a9633bd5696dc86674d09919c6b Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Thu, 4 Jul 2024 13:56:19 +0530 Subject: [PATCH 27/42] update readme --- README.md | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 104 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index cc695e2..49b1fb1 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,51 @@ - Video call creation and management - Chat session creation and management - Token generation for user authentication +- Command-line interface (CLI) for easy interaction +- Docker support for containerized usage ## Installation -To install the Stream Client Library, run the following command: +You can install the Stream Client Library using pip or pipx. + +### Using pip + +To install using pip, run the following command: ```sh pip install getstream ``` +### Using pipx + +For a more isolated and manageable installation, especially for CLI tools, we recommend using pipx. Pipx installs the package in its own virtual environment, making it available globally while keeping it isolated from other Python packages. + +First, install pipx if you haven't already: + +```sh +python -m pip install --user pipx +python -m pipx ensurepath +``` + +Then, install the Stream Client Library using pipx: + +```sh +pipx install getstream +``` + +This will make the `getstream` CLI command available globally on your system. + +> [!NOTE] +> Using pipx is particularly beneficial for the CLI functionality of the Stream SDK. It ensures that the CLI is always available without affecting your other Python projects or global Python environment. + +After installation with pipx, you can run CLI commands directly: + +```sh +getstream create-token --user-id your_user_id +``` + +For library usage in your Python projects, the standard pip installation is recommended. + ## Usage To get started, you need to import the `Stream` class from the library and create a new instance with your API key and secret: @@ -76,7 +112,6 @@ call.get_or_create( # Chat: update settings for a channel type ``` - ### Chat API - Channels To work with chat sessions, use the `client.chat` object and implement the desired chat methods in the `Chat` class: @@ -87,6 +122,58 @@ chat_instance = client.chat # TODO: implement and call chat-related methods with chat_instance ``` +## Command-Line Interface (CLI) + +The Stream SDK includes a CLI for easy interaction with the API. You can use it to perform various operations such as creating tokens, managing video calls, and more. + +To use the CLI, run: + +```sh +python -m getstream.cli [command] [options] +``` + +For example, to create a token: + +```sh +python -m getstream.cli create-token --user-id your_user_id +``` + +For more information on available commands, run: + +```sh +python -m getstream.cli --help +``` + +## Docker Support + +The Stream SDK can be run in a Docker container. This is useful for deployment and consistent development environments. + +### Building the Docker Image + +To build the Docker image, run: + +```sh +make docker-build +``` + +### Running Commands in Docker + +To run a CLI command using Docker: + +```sh +make docker-run CMD='create-token --user-id your_user_id' +``` + +Make sure to set the `STREAM_API_KEY` and `STREAM_API_SECRET` environment variables when running Docker commands. + +### Pushing the Docker Image + +To push the Docker image to the GitHub Container Registry (usually done by CI): + +```sh +make docker-push VERSION=1.0.0 +``` + ## Development We use poetry to manage dependencies and run tests. It's a package manager for Python that allows you to declare the libraries your project depends on and manage them. @@ -105,7 +192,7 @@ poetry shell To run tests, create a `.env` using the `.env.example` and adjust it to have valid API credentials ```sh -poetry run pytest tests/ getstream/ +make test ``` Before pushing changes make sure to have git hooks installed correctly, so that you get linting done locally `pre-commit install` @@ -113,7 +200,19 @@ Before pushing changes make sure to have git hooks installed correctly, so that You can also run the code formatting yourself if needed: ```sh -poetry run ruff format getstream/ tests/ +make format +``` + +To run the linter: + +```sh +make lint +``` + +To fix linter issues automatically: + +```sh +make fix ``` ### Writing new tests @@ -133,4 +232,4 @@ This project is licensed under the [MIT License](LICENSE). ## Contributing -Contributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md) to get started. +Contributions are welcome! Please read the [contributing guidelines](CONTRIBUTING.md) to get started. \ No newline at end of file From 7cab37ad745bf8d57bea37b53fa0ffba63525cd1 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 13:23:05 +0530 Subject: [PATCH 28/42] rename to stream-cli --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a972a3f..78af9c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,4 +36,4 @@ build-backend = "poetry.core.masonry.api" ignore = ["F405", "F403"] [tool.poetry.scripts] -cli = "getstream.cli:main" +stream-cli = "getstream.cli:main" From caa764f7d068990378c56da3656da6485e2e3ff2 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 13:23:57 +0530 Subject: [PATCH 29/42] fix make command --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 5374c94..dfb0ef8 100644 --- a/Makefile +++ b/Makefile @@ -81,7 +81,7 @@ clean: .PHONY: run run: - $(POETRY) run cli + $(POETRY) run stream-cli .PHONY: pipx-install pipx-install: From 326f8caf481c0bd957708927715d77f08aadea03 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 13:25:28 +0530 Subject: [PATCH 30/42] remove this log --- getstream/cli/utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index 20cedc4..5a640c0 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -193,7 +193,6 @@ def add_option_from_arg(cmd, param_name, param): """ type_name = get_type_name(param.annotation) - print(f"Adding option for {param_name} with type {type_name}") if type_name == "bool": cmd = click.option(f"--{param_name}", is_flag=True, default=False)(cmd) From f45013b94aab316dc6b534bca3595a1bf61a58f4 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 13:35:25 +0530 Subject: [PATCH 31/42] completion + optional python-dotenv --- getstream/cli/completion.py | 5 +++++ pyproject.toml | 12 ++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) create mode 100644 getstream/cli/completion.py diff --git a/getstream/cli/completion.py b/getstream/cli/completion.py new file mode 100644 index 0000000..0a4a288 --- /dev/null +++ b/getstream/cli/completion.py @@ -0,0 +1,5 @@ +from . import main + +def completion(): + """Returns the Click command object for completion""" + return main \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 78af9c1..3be2751 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,7 +18,6 @@ click = "^8.1.7" [tool.poetry.group.dev.dependencies] python-dateutil = "^2.8.2" -python-dotenv = "^1.0.0" pytest = "^7.3.1" flake8 = "^6.0.0" @@ -36,4 +35,13 @@ build-backend = "poetry.core.masonry.api" ignore = ["F405", "F403"] [tool.poetry.scripts] -stream-cli = "getstream.cli:main" +stream-cli = { callable = "getstream.cli:main", extras = ["cli"] } + +[tool.poetry.plugins."getstream.completion"] +stream-cli = "getstream.cli.completion:completion" + +[tool.poetry.group.cli] +optional = true + +[tool.poetry.group.cli.dependencies] +python-dotenv = "^1.0.0" \ No newline at end of file From dfd00a9aea9147f60597fb9b71a411fc95f3ee7f Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 13:37:33 +0530 Subject: [PATCH 32/42] poetry update --- poetry.lock | 131 ++++++++++++++++++++++------------------------------ 1 file changed, 56 insertions(+), 75 deletions(-) diff --git a/poetry.lock b/poetry.lock index 412c273..0248127 100644 --- a/poetry.lock +++ b/poetry.lock @@ -2,13 +2,13 @@ [[package]] name = "anyio" -version = "4.3.0" +version = "4.4.0" description = "High level compatibility layer for multiple asynchronous event loop implementations" optional = false python-versions = ">=3.8" files = [ - {file = "anyio-4.3.0-py3-none-any.whl", hash = "sha256:048e05d0f6caeed70d731f3db756d35dcc1f35747c8c403364a8332c630441b8"}, - {file = "anyio-4.3.0.tar.gz", hash = "sha256:f75253795a87df48568485fd18cdd2a3fa5c4f7c5be8e5e36637733fce06fed6"}, + {file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"}, + {file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"}, ] [package.dependencies] @@ -24,13 +24,13 @@ trio = ["trio (>=0.23)"] [[package]] name = "certifi" -version = "2024.2.2" +version = "2024.7.4" description = "Python package for providing Mozilla's CA Bundle." optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, - {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, + {file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"}, + {file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"}, ] [[package]] @@ -71,13 +71,13 @@ files = [ [[package]] name = "dataclasses-json" -version = "0.6.5" +version = "0.6.7" description = "Easily serialize dataclasses to and from JSON." optional = false python-versions = "<4.0,>=3.7" files = [ - {file = "dataclasses_json-0.6.5-py3-none-any.whl", hash = "sha256:f49c77aa3a85cac5bf5b7f65f4790ca0d2be8ef4d92c75e91ba0103072788a39"}, - {file = "dataclasses_json-0.6.5.tar.gz", hash = "sha256:1c287594d9fcea72dc42d6d3836cf14848c2dc5ce88f65ed61b36b57f515fe26"}, + {file = "dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a"}, + {file = "dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0"}, ] [package.dependencies] @@ -111,18 +111,18 @@ test = ["pytest (>=6)"] [[package]] name = "filelock" -version = "3.14.0" +version = "3.15.4" description = "A platform independent file lock." optional = false python-versions = ">=3.8" files = [ - {file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"}, - {file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"}, + {file = "filelock-3.15.4-py3-none-any.whl", hash = "sha256:6ca1fffae96225dab4c6eaf1c4f4f28cd2568d3ec2a44e15a08520504de468e7"}, + {file = "filelock-3.15.4.tar.gz", hash = "sha256:2207938cbc1844345cb01a5a95524dae30f0ce089eba5b00378295a17e3e90cb"}, ] [package.extras] docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-asyncio (>=0.21)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)", "virtualenv (>=20.26.2)"] typing = ["typing-extensions (>=4.8)"] [[package]] @@ -235,13 +235,13 @@ files = [ [[package]] name = "marshmallow" -version = "3.21.1" +version = "3.21.3" description = "A lightweight library for converting complex datatypes to and from native Python datatypes." optional = false python-versions = ">=3.8" files = [ - {file = "marshmallow-3.21.1-py3-none-any.whl", hash = "sha256:f085493f79efb0644f270a9bf2892843142d80d7174bbbd2f3713f2a589dc633"}, - {file = "marshmallow-3.21.1.tar.gz", hash = "sha256:4e65e9e0d80fc9e609574b9983cf32579f305c718afb30d7233ab818571768c3"}, + {file = "marshmallow-3.21.3-py3-none-any.whl", hash = "sha256:86ce7fb914aa865001a4b2092c4c2872d13bc347f3d42673272cabfdbad386f1"}, + {file = "marshmallow-3.21.3.tar.gz", hash = "sha256:4f57c5e050a54d66361e826f94fba213eb10b67b2fdb02c3e0343ce207ba1662"}, ] [package.dependencies] @@ -249,7 +249,7 @@ packaging = ">=17.0" [package.extras] dev = ["marshmallow[tests]", "pre-commit (>=3.5,<4.0)", "tox"] -docs = ["alabaster (==0.7.16)", "autodocsumm (==0.2.12)", "sphinx (==7.2.6)", "sphinx-issues (==4.0.0)", "sphinx-version-warning (==1.1.2)"] +docs = ["alabaster (==0.7.16)", "autodocsumm (==0.2.12)", "sphinx (==7.3.7)", "sphinx-issues (==4.1.0)", "sphinx-version-warning (==1.1.2)"] tests = ["pytest", "pytz", "simplejson"] [[package]] @@ -276,38 +276,35 @@ files = [ [[package]] name = "nodeenv" -version = "1.8.0" +version = "1.9.1" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[package.dependencies] -setuptools = "*" - [[package]] name = "packaging" -version = "24.0" +version = "24.1" description = "Core utilities for Python packages" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "packaging-24.0-py3-none-any.whl", hash = "sha256:2ddfb553fdf02fb784c234c7ba6ccc288296ceabec964ad2eae3777778130bc5"}, - {file = "packaging-24.0.tar.gz", hash = "sha256:eb82c5e3e56209074766e6885bb04b8c38a0c015d0a30036ebe7ece34c9989e9"}, + {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, + {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, ] [[package]] name = "platformdirs" -version = "4.2.1" +version = "4.2.2" description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." optional = false python-versions = ">=3.8" files = [ - {file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"}, - {file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"}, + {file = "platformdirs-4.2.2-py3-none-any.whl", hash = "sha256:2d7a1657e36a80ea911db832a8a6ece5ee53d8de21edd5cc5879af6530b1bfee"}, + {file = "platformdirs-4.2.2.tar.gz", hash = "sha256:38b7b51f512eed9e84a22788b4bce1de17c0adb134d6becb09836e37d8654cd3"}, ] [package.extras] @@ -332,13 +329,13 @@ testing = ["pytest", "pytest-benchmark"] [[package]] name = "pre-commit" -version = "3.7.0" +version = "3.7.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false python-versions = ">=3.9" files = [ - {file = "pre_commit-3.7.0-py2.py3-none-any.whl", hash = "sha256:5eae9e10c2b5ac51577c3452ec0a490455c45a0533f7960f993a0d01e59decab"}, - {file = "pre_commit-3.7.0.tar.gz", hash = "sha256:e209d61b8acdcf742404408531f0c37d49d2c734fd7cff2d6076083d191cb060"}, + {file = "pre_commit-3.7.1-py2.py3-none-any.whl", hash = "sha256:fae36fd1d7ad7d6a5a1c0b0d5adb2ed1a3bda5a21bf6c3e5372073d7a11cd4c5"}, + {file = "pre_commit-3.7.1.tar.gz", hash = "sha256:8ca3ad567bc78a4972a3f1a477e94a79d4597e8140a6e0b651c5e33899c3654a"}, ] [package.dependencies] @@ -516,46 +513,30 @@ files = [ [[package]] name = "ruff" -version = "0.4.2" +version = "0.4.10" description = "An extremely fast Python linter and code formatter, written in Rust." optional = false python-versions = ">=3.7" files = [ - {file = "ruff-0.4.2-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:8d14dc8953f8af7e003a485ef560bbefa5f8cc1ad994eebb5b12136049bbccc5"}, - {file = "ruff-0.4.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:24016ed18db3dc9786af103ff49c03bdf408ea253f3cb9e3638f39ac9cf2d483"}, - {file = "ruff-0.4.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e2e06459042ac841ed510196c350ba35a9b24a643e23db60d79b2db92af0c2b"}, - {file = "ruff-0.4.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3afabaf7ba8e9c485a14ad8f4122feff6b2b93cc53cd4dad2fd24ae35112d5c5"}, - {file = "ruff-0.4.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:799eb468ea6bc54b95527143a4ceaf970d5aa3613050c6cff54c85fda3fde480"}, - {file = "ruff-0.4.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ec4ba9436a51527fb6931a8839af4c36a5481f8c19e8f5e42c2f7ad3a49f5069"}, - {file = "ruff-0.4.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6a2243f8f434e487c2a010c7252150b1fdf019035130f41b77626f5655c9ca22"}, - {file = "ruff-0.4.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8772130a063f3eebdf7095da00c0b9898bd1774c43b336272c3e98667d4fb8fa"}, - {file = "ruff-0.4.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ab165ef5d72392b4ebb85a8b0fbd321f69832a632e07a74794c0e598e7a8376"}, - {file = "ruff-0.4.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1f32cadf44c2020e75e0c56c3408ed1d32c024766bd41aedef92aa3ca28eef68"}, - {file = "ruff-0.4.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:22e306bf15e09af45ca812bc42fa59b628646fa7c26072555f278994890bc7ac"}, - {file = "ruff-0.4.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:82986bb77ad83a1719c90b9528a9dd663c9206f7c0ab69282af8223566a0c34e"}, - {file = "ruff-0.4.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:652e4ba553e421a6dc2a6d4868bc3b3881311702633eb3672f9f244ded8908cd"}, - {file = "ruff-0.4.2-py3-none-win32.whl", hash = "sha256:7891ee376770ac094da3ad40c116258a381b86c7352552788377c6eb16d784fe"}, - {file = "ruff-0.4.2-py3-none-win_amd64.whl", hash = "sha256:5ec481661fb2fd88a5d6cf1f83403d388ec90f9daaa36e40e2c003de66751798"}, - {file = "ruff-0.4.2-py3-none-win_arm64.whl", hash = "sha256:cbd1e87c71bca14792948c4ccb51ee61c3296e164019d2d484f3eaa2d360dfaf"}, - {file = "ruff-0.4.2.tar.gz", hash = "sha256:33bcc160aee2520664bc0859cfeaebc84bb7323becff3f303b8f1f2d81cb4edc"}, -] - -[[package]] -name = "setuptools" -version = "69.5.1" -description = "Easily download, build, install, upgrade, and uninstall Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "setuptools-69.5.1-py3-none-any.whl", hash = "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"}, - {file = "setuptools-69.5.1.tar.gz", hash = "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987"}, + {file = "ruff-0.4.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5c2c4d0859305ac5a16310eec40e4e9a9dec5dcdfbe92697acd99624e8638dac"}, + {file = "ruff-0.4.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:a79489607d1495685cdd911a323a35871abfb7a95d4f98fc6f85e799227ac46e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1dd1681dfa90a41b8376a61af05cc4dc5ff32c8f14f5fe20dba9ff5deb80cd6"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c75c53bb79d71310dc79fb69eb4902fba804a81f374bc86a9b117a8d077a1784"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18238c80ee3d9100d3535d8eb15a59c4a0753b45cc55f8bf38f38d6a597b9739"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d8f71885bce242da344989cae08e263de29752f094233f932d4f5cfb4ef36a81"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:330421543bd3222cdfec481e8ff3460e8702ed1e58b494cf9d9e4bf90db52b9d"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e9b6fb3a37b772628415b00c4fc892f97954275394ed611056a4b8a2631365e"}, + {file = "ruff-0.4.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f54c481b39a762d48f64d97351048e842861c6662d63ec599f67d515cb417f6"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:67fe086b433b965c22de0b4259ddfe6fa541c95bf418499bedb9ad5fb8d1c631"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:acfaaab59543382085f9eb51f8e87bac26bf96b164839955f244d07125a982ef"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:3cea07079962b2941244191569cf3a05541477286f5cafea638cd3aa94b56815"}, + {file = "ruff-0.4.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:338a64ef0748f8c3a80d7f05785930f7965d71ca260904a9321d13be24b79695"}, + {file = "ruff-0.4.10-py3-none-win32.whl", hash = "sha256:ffe3cd2f89cb54561c62e5fa20e8f182c0a444934bf430515a4b422f1ab7b7ca"}, + {file = "ruff-0.4.10-py3-none-win_amd64.whl", hash = "sha256:67f67cef43c55ffc8cc59e8e0b97e9e60b4837c8f21e8ab5ffd5d66e196e25f7"}, + {file = "ruff-0.4.10-py3-none-win_arm64.whl", hash = "sha256:dd1fcee327c20addac7916ca4e2653fbbf2e8388d8a6477ce5b4e986b68ae6c0"}, + {file = "ruff-0.4.10.tar.gz", hash = "sha256:3aa4f2bc388a30d346c56524f7cacca85945ba124945fe489952aadb6b5cd804"}, ] -[package.extras] -docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] -testing = ["build[virtualenv]", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] -testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] - [[package]] name = "six" version = "1.16.0" @@ -591,13 +572,13 @@ files = [ [[package]] name = "typing-extensions" -version = "4.11.0" +version = "4.12.2" description = "Backported and Experimental Type Hints for Python 3.8+" optional = false python-versions = ">=3.8" files = [ - {file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"}, - {file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"}, + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, ] [[package]] @@ -617,13 +598,13 @@ typing-extensions = ">=3.7.4" [[package]] name = "virtualenv" -version = "20.26.1" +version = "20.26.3" description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "virtualenv-20.26.1-py3-none-any.whl", hash = "sha256:7aa9982a728ae5892558bff6a2839c00b9ed145523ece2274fad6f414690ae75"}, - {file = "virtualenv-20.26.1.tar.gz", hash = "sha256:604bfdceaeece392802e6ae48e69cec49168b9c5f4a44e483963f9242eb0e78b"}, + {file = "virtualenv-20.26.3-py3-none-any.whl", hash = "sha256:8cc4a31139e796e9a7de2cd5cf2489de1217193116a8fd42328f1bd65f434589"}, + {file = "virtualenv-20.26.3.tar.gz", hash = "sha256:4c43a2a236279d9ea36a0d76f98d84bd6ca94ac4e0f4a3b9d46d05e10fea542a"}, ] [package.dependencies] @@ -638,4 +619,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0.0" -content-hash = "40e10402f29baab472794ed17303a5b50fc6df96a05575ad58d0fd542991136e" +content-hash = "bddef33d2f71a0d32f6e715767f777e0897f3ac9a41c71f4d3cf03d048186914" From cfdd3f7d5d386fa48973ecad2256a2244619aa79 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 13:48:19 +0530 Subject: [PATCH 33/42] update readme --- README.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 49b1fb1..14e8195 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,14 @@ python -m pipx ensurepath Then, install the Stream Client Library using pipx: ```sh -pipx install getstream +pipx install getstream[cli] +pipx upgrade getstream +``` + +To uninstall the package, run: + +```sh +pipx uninstall getstream ``` This will make the `getstream` CLI command available globally on your system. From f6e049fb2e4fea4f50bead466e887479326c9036 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 14:57:09 +0530 Subject: [PATCH 34/42] revert py dotenv change to prepare for config --- pyproject.toml | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 3be2751..bd96b52 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ click = "^8.1.7" [tool.poetry.group.dev.dependencies] python-dateutil = "^2.8.2" +python-dotenv = "^1.0.0" pytest = "^7.3.1" flake8 = "^6.0.0" @@ -35,13 +36,7 @@ build-backend = "poetry.core.masonry.api" ignore = ["F405", "F403"] [tool.poetry.scripts] -stream-cli = { callable = "getstream.cli:main", extras = ["cli"] } +stream-cli = "getstream.cli:main" [tool.poetry.plugins."getstream.completion"] -stream-cli = "getstream.cli.completion:completion" - -[tool.poetry.group.cli] -optional = true - -[tool.poetry.group.cli.dependencies] -python-dotenv = "^1.0.0" \ No newline at end of file +stream-cli = "getstream.cli.completion:completion" \ No newline at end of file From 1c7f41ef2ef50e448d4129ae15cb0aa840e42ade Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 14:57:24 +0530 Subject: [PATCH 35/42] config --- Makefile | 2 +- getstream/cli/__init__.py | 31 +++++----- getstream/cli/configure.py | 117 +++++++++++++++++++++++++++++++++++++ getstream/cli/utils.py | 3 +- getstream/cli/video.py | 7 ++- 5 files changed, 140 insertions(+), 20 deletions(-) create mode 100644 getstream/cli/configure.py diff --git a/Makefile b/Makefile index dfb0ef8..a6f8de7 100644 --- a/Makefile +++ b/Makefile @@ -81,7 +81,7 @@ clean: .PHONY: run run: - $(POETRY) run stream-cli + $(POETRY) run stream-cli $(ARGS) .PHONY: pipx-install pipx-install: diff --git a/getstream/cli/__init__.py b/getstream/cli/__init__.py index 3bb1eae..68316e4 100644 --- a/getstream/cli/__init__.py +++ b/getstream/cli/__init__.py @@ -1,24 +1,27 @@ import click -from dotenv import load_dotenv from typing import Optional from getstream import Stream +from getstream.cli.configure import configure, debug_config, debug_permissions, get_credentials, show_config from getstream.cli.utils import pass_client from getstream.cli.video import video from getstream.stream import BASE_URL - @click.group() -@click.option("--api-key") -@click.option("--api-secret") +@click.option("--profile", default='default', help="Configuration profile to use") @click.option("--base-url", default=BASE_URL, show_default=True) @click.option("--timeout", default=3.0, show_default=True) @click.pass_context -def cli(ctx: click.Context, api_key: str, api_secret: str, base_url: str, timeout=3.0): +def cli(ctx, profile, base_url, timeout): ctx.ensure_object(dict) + api_key, api_secret, app_name = get_credentials(profile) + if api_key is None or api_secret is None: + click.echo(f"Error: Unable to load credentials for profile '{profile}'.") + click.echo(f"Please run 'stream-cli configure' to set up your profile.") + ctx.exit(1) ctx.obj["client"] = Stream( api_key=api_key, api_secret=api_secret, timeout=timeout, base_url=base_url ) - + ctx.obj["app_name"] = app_name @click.command() @click.option("--user-id", required=True) @@ -28,6 +31,7 @@ def cli(ctx: click.Context, api_key: str, api_secret: str, base_url: str, timeou @pass_client def create_token( client: Stream, + app_name: str, user_id: str, call_cid=None, role: Optional[str] = None, @@ -42,16 +46,15 @@ def create_token( else: print(client.create_call_token(user_id=user_id)) - +cli.add_command(debug_config) +cli.add_command(debug_permissions) +cli.add_command(configure) +cli.add_command(show_config) cli.add_command(create_token) -cli.add_command(video) -# cli.add_command(chat) - +cli.add_command(video) # Add video command directly to the main CLI group def main(): - load_dotenv() - cli(auto_envvar_prefix="STREAM", obj={}) - + cli(obj={}) if __name__ == "__main__": - main() + main() \ No newline at end of file diff --git a/getstream/cli/configure.py b/getstream/cli/configure.py new file mode 100644 index 0000000..8e075e5 --- /dev/null +++ b/getstream/cli/configure.py @@ -0,0 +1,117 @@ +import configparser +import os +import sys + +import click + +CONFIG_PATH = os.path.expanduser('~/stream_cli_config.ini') + +def get_config(): + config = configparser.ConfigParser() + if os.path.exists(CONFIG_PATH): + config.read(CONFIG_PATH) + return config + +def save_config(config): + try: + with open(CONFIG_PATH, 'w') as configfile: + config.write(configfile) + click.echo(f"Configuration successfully written to {CONFIG_PATH}") + except IOError as e: + click.echo(f"Error writing configuration file: {e}", err=True) + click.echo(f"Attempted to write to: {CONFIG_PATH}", err=True) + sys.exit(1) + +@click.command() +@click.option('--profile', default='default', help='Profile name') +@click.option('--api-key', prompt=True, help='API Key') +@click.option('--api-secret', prompt=True, hide_input=True, confirmation_prompt=True, help='API Secret') +@click.option('--app-name', prompt=True, help='Application Name') +def configure(profile, api_key, api_secret, app_name): + config = get_config() + if not config.has_section(profile): + config.add_section(profile) + + config[profile]['api_key'] = api_key + config[profile]['api_secret'] = api_secret + config[profile]['app_name'] = app_name + + save_config(config) + click.echo(f"Configuration for profile '{profile}' has been updated.") + click.echo(f"Config file saved at: {CONFIG_PATH}") + click.echo(f"API Key: {mask_value(api_key)}") + click.echo(f"API Secret: {mask_value(api_secret)}") + click.echo(f"App Name: {app_name}") + + +def get_credentials(profile='default'): + config = get_config() + if not config.has_section(profile): + click.echo(f"Error: Profile '{profile}' not found.") + click.echo(f"Config file path: {CONFIG_PATH}") + click.echo(f"Available profiles: {', '.join(config.sections())}") + return None, None, None + + section = config[profile] + api_key = section.get('api_key') + api_secret = section.get('api_secret') + app_name = section.get('app_name') + + if not all([api_key, api_secret, app_name]): + click.echo(f"Error: Incomplete configuration for profile '{profile}'.") + click.echo(f"API Key: {'Set' if api_key else 'Not set'}") + click.echo(f"API Secret: {'Set' if api_secret else 'Not set'}") + click.echo(f"App Name: {'Set' if app_name else 'Not set'}") + return None, None, None + + return api_key, api_secret, app_name + +def mask_value(value): + if len(value) <= 3: + return '*' * len(value) + return '*' * (len(value) - 3) + value[-3:] + + +@click.command() +@click.option('--profile', default='default', help='Profile name to display') +def show_config(profile): + config = get_config() + if not config.has_section(profile): + click.echo(f"Profile '{profile}' not found.") + return + + click.echo(f"Configuration for profile '{profile}':") + click.echo(f"API Key: {mask_value(config[profile]['api_key'])}") + click.echo(f"API Secret: {mask_value(config[profile]['api_secret'])}") + click.echo(f"App Name: {config[profile]['app_name']}") + + +@click.command() +def debug_config(): + """Debug configuration""" + click.echo(f"Config file path: {CONFIG_PATH}") + click.echo(f"Config file exists: {os.path.exists(CONFIG_PATH)}") + if os.path.exists(CONFIG_PATH): + with open(CONFIG_PATH, 'r') as f: + click.echo("Config file contents:") + click.echo(f.read()) + else: + click.echo("Config file does not exist.") + +@click.command() +def debug_permissions(): + """Debug file permissions""" + home = os.path.expanduser('~') + click.echo(f"Home directory: {home}") + click.echo(f"Home directory writable: {os.access(home, os.W_OK)}") + + config_dir = os.path.dirname(CONFIG_PATH) + click.echo(f"Config directory: {config_dir}") + click.echo(f"Config directory exists: {os.path.exists(config_dir)}") + if os.path.exists(config_dir): + click.echo(f"Config directory writable: {os.access(config_dir, os.W_OK)}") + + click.echo(f"Config file path: {CONFIG_PATH}") + click.echo(f"Config file exists: {os.path.exists(CONFIG_PATH)}") + if os.path.exists(CONFIG_PATH): + click.echo(f"Config file writable: {os.access(CONFIG_PATH, os.W_OK)}") diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py index 5a640c0..a9f8560 100644 --- a/getstream/cli/utils.py +++ b/getstream/cli/utils.py @@ -21,8 +21,7 @@ def do_something(client: Stream, some_option): @click.pass_context def new_func(ctx, *args, **kwargs): - return ctx.invoke(f, ctx.obj["client"], *args, **kwargs) - + return ctx.invoke(f, ctx.obj["client"], ctx.obj["app_name"], *args, **kwargs) return update_wrapper(new_func, f) diff --git a/getstream/cli/video.py b/getstream/cli/video.py index fc83f1a..2166cf9 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -45,7 +45,8 @@ def create_call_command_from_method(name, method): @click.option("--call-type", required=True, help="The type of the call") @click.option("--call-id", required=True, help="The ID of the call") @pass_client - def cmd(client, call_type, call_id, **kwargs): + def cmd(client:Stream, app_name:str,call_type:str, call_id:str, **kwargs): + call = client.video.call(call_type, call_id) # Parse complex types and handle boolean flags @@ -105,7 +106,7 @@ def create_command_from_method(name, method): @click.command(name=name) @pass_client - def cmd(client, **kwargs): + def cmd(client:Stream,app_name:str, **kwargs): # Parse complex types sig = inspect.signature(method) for param_name, param in sig.parameters.items(): @@ -246,7 +247,7 @@ def video(): @click.command() @click.option("--rtmp-user-id", default=f"{uuid.uuid4()}") @pass_client -def rtmp_in_setup(client: Stream, rtmp_user_id: str): +def rtmp_in_setup(client: Stream,app_name:str, rtmp_user_id: str): call = client.video.call("default", f"rtmp-in-{uuid.uuid4()}").get_or_create( data=CallRequest( created_by_id=rtmp_user_id, From b6e7e2eb63718e54c476ca3582221ad408e0ca82 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 16:33:52 +0530 Subject: [PATCH 36/42] fix configure + completion install command --- getstream/cli/__init__.py | 12 +++++-- getstream/cli/completion.py | 31 ++++++++++++++++-- getstream/cli/configure.py | 64 ++++++++----------------------------- 3 files changed, 52 insertions(+), 55 deletions(-) diff --git a/getstream/cli/__init__.py b/getstream/cli/__init__.py index 68316e4..2bc1837 100644 --- a/getstream/cli/__init__.py +++ b/getstream/cli/__init__.py @@ -1,7 +1,8 @@ import click from typing import Optional from getstream import Stream -from getstream.cli.configure import configure, debug_config, debug_permissions, get_credentials, show_config +from getstream.cli.completion import install_completion_command +from getstream.cli.configure import configure, get_credentials, show_config from getstream.cli.utils import pass_client from getstream.cli.video import video from getstream.stream import BASE_URL @@ -13,6 +14,10 @@ @click.pass_context def cli(ctx, profile, base_url, timeout): ctx.ensure_object(dict) + + if ctx.invoked_subcommand == 'configure': + return # Skip credential check for 'configure' command + api_key, api_secret, app_name = get_credentials(profile) if api_key is None or api_secret is None: click.echo(f"Error: Unable to load credentials for profile '{profile}'.") @@ -46,8 +51,9 @@ def create_token( else: print(client.create_call_token(user_id=user_id)) -cli.add_command(debug_config) -cli.add_command(debug_permissions) +cli.add_command(install_completion_command()) +# cli.add_command(debug_config) +# cli.add_command(debug_permissions) cli.add_command(configure) cli.add_command(show_config) cli.add_command(create_token) diff --git a/getstream/cli/completion.py b/getstream/cli/completion.py index 0a4a288..940f1a8 100644 --- a/getstream/cli/completion.py +++ b/getstream/cli/completion.py @@ -1,5 +1,32 @@ -from . import main +import os +import sys +import click +from getstream.cli.configure import CONFIG_DIR, ensure_config_dir + +COMPLETION_PATH = { + 'bash': os.path.join(CONFIG_DIR, 'completion.bash'), + 'zsh': os.path.join(CONFIG_DIR, 'completion.zsh'), + 'fish': os.path.join(CONFIG_DIR, 'completion.fish') +} + + +def install_completion_command(): + @click.command() + @click.option('--shell', type=click.Choice(['bash', 'zsh', 'fish']), required=True) + def install_completion(shell): + """Install the completion script for the specified shell.""" + ensure_config_dir() + script = f"_{os.path.basename(sys.argv[0]).replace('-', '_').upper()}_COMPLETE={shell}_source {sys.argv[0]}" + path = COMPLETION_PATH[shell] + with open(path, 'w') as f: + f.write(script) + click.echo(f"Completion script installed at {path}") + click.echo(f"Add the following line to your ~/.{shell}rc:") + click.echo(f"source {path}") + + return install_completion def completion(): """Returns the Click command object for completion""" - return main \ No newline at end of file + from . import cli # Import here to avoid circular imports + return cli \ No newline at end of file diff --git a/getstream/cli/configure.py b/getstream/cli/configure.py index 8e075e5..3022cf0 100644 --- a/getstream/cli/configure.py +++ b/getstream/cli/configure.py @@ -1,10 +1,12 @@ import configparser import os -import sys - import click -CONFIG_PATH = os.path.expanduser('~/stream_cli_config.ini') +CONFIG_DIR = os.path.expanduser('~/.stream-cli') +CONFIG_PATH = os.path.join(CONFIG_DIR, 'config.ini') + +def ensure_config_dir(): + os.makedirs(CONFIG_DIR, exist_ok=True) def get_config(): config = configparser.ConfigParser() @@ -13,14 +15,9 @@ def get_config(): return config def save_config(config): - try: - with open(CONFIG_PATH, 'w') as configfile: - config.write(configfile) - click.echo(f"Configuration successfully written to {CONFIG_PATH}") - except IOError as e: - click.echo(f"Error writing configuration file: {e}", err=True) - click.echo(f"Attempted to write to: {CONFIG_PATH}", err=True) - sys.exit(1) + ensure_config_dir() + with open(CONFIG_PATH, 'w') as configfile: + config.write(configfile) @click.command() @click.option('--profile', default='default', help='Profile name') @@ -43,7 +40,6 @@ def configure(profile, api_key, api_secret, app_name): click.echo(f"API Secret: {mask_value(api_secret)}") click.echo(f"App Name: {app_name}") - def get_credentials(profile='default'): config = get_config() if not config.has_section(profile): @@ -66,12 +62,6 @@ def get_credentials(profile='default'): return api_key, api_secret, app_name -def mask_value(value): - if len(value) <= 3: - return '*' * len(value) - return '*' * (len(value) - 3) + value[-3:] - - @click.command() @click.option('--profile', default='default', help='Profile name to display') def show_config(profile): @@ -81,37 +71,11 @@ def show_config(profile): return click.echo(f"Configuration for profile '{profile}':") - click.echo(f"API Key: {mask_value(config[profile]['api_key'])}") - click.echo(f"API Secret: {mask_value(config[profile]['api_secret'])}") + click.echo(f"API Key: {'*' * 10}{config[profile]['api_key'][-3:]}") + click.echo(f"API Secret: {'*' * 13}") click.echo(f"App Name: {config[profile]['app_name']}") - -@click.command() -def debug_config(): - """Debug configuration""" - click.echo(f"Config file path: {CONFIG_PATH}") - click.echo(f"Config file exists: {os.path.exists(CONFIG_PATH)}") - if os.path.exists(CONFIG_PATH): - with open(CONFIG_PATH, 'r') as f: - click.echo("Config file contents:") - click.echo(f.read()) - else: - click.echo("Config file does not exist.") - -@click.command() -def debug_permissions(): - """Debug file permissions""" - home = os.path.expanduser('~') - click.echo(f"Home directory: {home}") - click.echo(f"Home directory writable: {os.access(home, os.W_OK)}") - - config_dir = os.path.dirname(CONFIG_PATH) - click.echo(f"Config directory: {config_dir}") - click.echo(f"Config directory exists: {os.path.exists(config_dir)}") - if os.path.exists(config_dir): - click.echo(f"Config directory writable: {os.access(config_dir, os.W_OK)}") - - click.echo(f"Config file path: {CONFIG_PATH}") - click.echo(f"Config file exists: {os.path.exists(CONFIG_PATH)}") - if os.path.exists(CONFIG_PATH): - click.echo(f"Config file writable: {os.access(CONFIG_PATH, os.W_OK)}") +def mask_value(value): + if len(value) <= 3: + return '*' * len(value) + return '*' * (len(value) - 3) + value[-3:] \ No newline at end of file From dc2ad0dc021c5e7691690de8845d8f6e0b5f8b10 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 16:37:53 +0530 Subject: [PATCH 37/42] improve instructions --- getstream/cli/completion.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/getstream/cli/completion.py b/getstream/cli/completion.py index 940f1a8..e95b519 100644 --- a/getstream/cli/completion.py +++ b/getstream/cli/completion.py @@ -21,8 +21,9 @@ def install_completion(shell): with open(path, 'w') as f: f.write(script) click.echo(f"Completion script installed at {path}") - click.echo(f"Add the following line to your ~/.{shell}rc:") - click.echo(f"source {path}") + click.echo(f"Add the following line to your ~/.{shell}rc by running:") + click.echo(f"echo 'source {path}' >> ~/.{shell}rc") + click.echo(f"Then restart your shell or run 'source ~/.{shell}rc' to enable completion.") return install_completion From a7ba61a547a055c2890f90cce71a4c3e681ab42d Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 16:44:26 +0530 Subject: [PATCH 38/42] stream-cli instructions --- README.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 14e8195..f6adda7 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,7 @@ python -m pipx ensurepath Then, install the Stream Client Library using pipx: ```sh -pipx install getstream[cli] -pipx upgrade getstream +pipx install getstream ``` To uninstall the package, run: @@ -54,9 +53,20 @@ This will make the `getstream` CLI command available globally on your system. After installation with pipx, you can run CLI commands directly: ```sh -getstream create-token --user-id your_user_id +stream-cli create-token --user-id your_user_id ``` +### Configuration and Completion + +Your Stream CLI configuration and completion scripts are stored in `~/.stream-cli/`. + +To set up: + +1. Run `stream-cli configure` to set up your configuration. +2. Install completion for your shell:`stream-cli install-completion --shell bash # or zsh, or fish` +3. Add the suggested line to your shell's RC file (e.g., ~/.bashrc, ~/.zshrc, or ~/.config/fish/config.fish). +4. Restart your shell or source the RC file. + For library usage in your Python projects, the standard pip installation is recommended. ## Usage From 3e1cd13258f7e77008a23a7d421395c2573fa2f6 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 17:30:15 +0530 Subject: [PATCH 39/42] bring back support for env variables --- getstream/cli/configure.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/getstream/cli/configure.py b/getstream/cli/configure.py index 3022cf0..816d9eb 100644 --- a/getstream/cli/configure.py +++ b/getstream/cli/configure.py @@ -42,6 +42,15 @@ def configure(profile, api_key, api_secret, app_name): def get_credentials(profile='default'): config = get_config() + + # First, try to get credentials from environment variables + api_key = os.environ.get('STREAM_API_KEY') + api_secret = os.environ.get('STREAM_API_SECRET') + + # If environment variables are set, use them and return + if api_key and api_secret: + return api_key, api_secret, None # Note: app_name is not available from env vars + if not config.has_section(profile): click.echo(f"Error: Profile '{profile}' not found.") click.echo(f"Config file path: {CONFIG_PATH}") From 82546571362e058b59572fb2d7a2f30fe9300364 Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Fri, 5 Jul 2024 18:12:05 +0530 Subject: [PATCH 40/42] fix lock file --- poetry.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/poetry.lock b/poetry.lock index 0248127..8125fd2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -619,4 +619,4 @@ test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess [metadata] lock-version = "2.0" python-versions = ">=3.9,<4.0.0" -content-hash = "bddef33d2f71a0d32f6e715767f777e0897f3ac9a41c71f4d3cf03d048186914" +content-hash = "40e10402f29baab472794ed17303a5b50fc6df96a05575ad58d0fd542991136e" From 2eea290bf88f2a6f3ae321b5720f7daf020cfdbc Mon Sep 17 00:00:00 2001 From: sachaarbonel Date: Mon, 8 Jul 2024 12:26:12 +0530 Subject: [PATCH 41/42] fix lints --- getstream/cli/__init__.py | 2 +- tests/test_cli.py | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/getstream/cli/__init__.py b/getstream/cli/__init__.py index 2bc1837..e817e21 100644 --- a/getstream/cli/__init__.py +++ b/getstream/cli/__init__.py @@ -21,7 +21,7 @@ def cli(ctx, profile, base_url, timeout): api_key, api_secret, app_name = get_credentials(profile) if api_key is None or api_secret is None: click.echo(f"Error: Unable to load credentials for profile '{profile}'.") - click.echo(f"Please run 'stream-cli configure' to set up your profile.") + click.echo("Please run 'stream-cli configure' to set up your profile.") ctx.exit(1) ctx.obj["client"] = Stream( api_key=api_key, api_secret=api_secret, timeout=timeout, base_url=base_url diff --git a/tests/test_cli.py b/tests/test_cli.py index eb7a846..907d3e1 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -5,10 +5,10 @@ from getstream.models import CallRequest from getstream.cli.utils import get_type_name, parse_complex_type, add_option_from_arg import click -from tests.fixtures import mock_setup, cli_runner +from tests.fixtures import mock_setup, cli_runner # noqa -def test_create_token(mocker, cli_runner): +def test_create_token(mocker, cli_runner): # noqa # Mock the Stream client mock_stream = mocker.Mock() mock_stream.create_call_token.return_value = "mocked_token" @@ -147,7 +147,7 @@ def dummy_cmd(): assert cmd.params[-1].type == click.STRING # Assuming json_option uses STRING type -def test_video_call_get_or_create(mocker, cli_runner): +def test_video_call_get_or_create(mocker, cli_runner): # noqa mock_stream, mock_video_client, mock_call = mock_setup(mocker) # Mock the get_or_create method @@ -200,7 +200,7 @@ def test_video_call_get_or_create(mocker, cli_runner): assert call_args["data"]["members_limit"] == 10 -def test_cli_create_call_with_members(mocker, cli_runner): +def test_cli_create_call_with_members(mocker, cli_runner): # noqa mock_stream, mock_video_client, mock_call = mock_setup(mocker) result = cli_runner.invoke( @@ -233,7 +233,7 @@ def test_cli_create_call_with_members(mocker, cli_runner): assert call_args["data"]["members"][1]["user_id"] == "tommaso-id" -def test_cli_mute_all(mocker, cli_runner): +def test_cli_mute_all(mocker, cli_runner): # noqa mock_stream, mock_video_client, mock_call = mock_setup(mocker) result = cli_runner.invoke( @@ -273,7 +273,7 @@ def test_cli_mute_all(mocker, cli_runner): ) -def test_cli_block_user_from_call(mocker, cli_runner): +def test_cli_block_user_from_call(mocker, cli_runner): # noqa """ poetry run python -m getstream.cli video call block-user --call-type default --call-id 123456 --user_id bad-user-id """ @@ -295,7 +295,7 @@ def test_cli_block_user_from_call(mocker, cli_runner): assert result.exit_code == 0 -def test_cli_unblock_user_from_call(mocker, cli_runner): +def test_cli_unblock_user_from_call(mocker, cli_runner): # noqa """ poetry run python -m getstream.cli video call unblock-user --call-type default --call-id 123456 --user_id bad-user-id """ @@ -317,7 +317,7 @@ def test_cli_unblock_user_from_call(mocker, cli_runner): assert result.exit_code == 0 -def test_cli_send_custom_event(mocker, cli_runner): +def test_cli_send_custom_event(mocker, cli_runner): # noqa """ poetry run python -m getstream.cli video call send-call-event --call-type default --call-id 123456 --user_id user-id --custom '{"bananas": "good"}' """ @@ -341,7 +341,7 @@ def test_cli_send_custom_event(mocker, cli_runner): assert result.exit_code == 0 -def test_cli_update_settings(mocker, cli_runner): +def test_cli_update_settings(mocker, cli_runner): # noqa """ poetry run python -m getstream.cli video call update --call-type default --call-id 123456 --settings_override '{"screensharing": {"enabled": true, "access_request_enabled": true}}' """ From b2775c21e33c3cb8e038797d8c4766f0fc01c720 Mon Sep 17 00:00:00 2001 From: Tommaso Barbugli Date: Tue, 9 Jul 2024 21:12:35 +0200 Subject: [PATCH 42/42] ending calls --- getstream/cli/video.py | 1 + 1 file changed, 1 insertion(+) diff --git a/getstream/cli/video.py b/getstream/cli/video.py index 2166cf9..77eeff8 100644 --- a/getstream/cli/video.py +++ b/getstream/cli/video.py @@ -165,6 +165,7 @@ def print_result(result): # Define the call commands call_commands = { "get": {"method": Call.get}, + "end": {"method": Call.end}, "update": {"method": Call.update}, "delete": {"method": Call.delete}, "get-or-create": {"method": Call.get_or_create},