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 new file mode 100644 index 0000000..a6f8de7 --- /dev/null +++ b/Makefile @@ -0,0 +1,113 @@ +# Python interpreter +PYTHON := python3 + +# Poetry +POETRY := poetry + +# pipx +PIPX := pipx + +# Virtual environment +VENV := .venv + +# Source directory +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 + +# 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:" + @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" + @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" + @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: + $(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 stream-cli $(ARGS) + +.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 + +.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/README.md b/README.md index cc695e2..f6adda7 100644 --- a/README.md +++ b/README.md @@ -7,15 +7,68 @@ - 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 +``` + +To uninstall the package, run: + +```sh +pipx uninstall 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 +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 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 +129,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 +139,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 +209,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 +217,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 +249,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 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 diff --git a/getstream/cli/__init__.py b/getstream/cli/__init__.py new file mode 100644 index 0000000..e817e21 --- /dev/null +++ b/getstream/cli/__init__.py @@ -0,0 +1,66 @@ +import click +from typing import Optional +from getstream import Stream +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 + +@click.group() +@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, 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}'.") + 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 + ) + ctx.obj["app_name"] = app_name + +@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, + app_name: str, + 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( + 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(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) +cli.add_command(video) # Add video command directly to the main CLI group + +def main(): + cli(obj={}) + +if __name__ == "__main__": + main() \ No newline at end of file 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/completion.py b/getstream/cli/completion.py new file mode 100644 index 0000000..e95b519 --- /dev/null +++ b/getstream/cli/completion.py @@ -0,0 +1,33 @@ +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 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 + +def completion(): + """Returns the Click command object for completion""" + 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 new file mode 100644 index 0000000..816d9eb --- /dev/null +++ b/getstream/cli/configure.py @@ -0,0 +1,90 @@ +import configparser +import os +import click + +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() + if os.path.exists(CONFIG_PATH): + config.read(CONFIG_PATH) + return config + +def save_config(config): + ensure_config_dir() + with open(CONFIG_PATH, 'w') as configfile: + config.write(configfile) + +@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() + + # 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}") + 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 + +@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: {'*' * 10}{config[profile]['api_key'][-3:]}") + click.echo(f"API Secret: {'*' * 13}") + click.echo(f"App Name: {config[profile]['app_name']}") + +def mask_value(value): + if len(value) <= 3: + return '*' * len(value) + return '*' * (len(value) - 3) + value[-3:] \ No newline at end of file diff --git a/getstream/cli/utils.py b/getstream/cli/utils.py new file mode 100644 index 0000000..a9f8560 --- /dev/null +++ b/getstream/cli/utils.py @@ -0,0 +1,225 @@ +from functools import update_wrapper +import click + +from typing import get_origin, get_args, Optional, Union + + +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 + + @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"], ctx.obj["app_name"], *args, **kwargs) + return update_wrapper(new_func, f) + + +def json_option(option_name): + """ + 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): + 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 + + +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: + 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__ + + return str(annotation) + + +def parse_complex_type(value, annotation): + """ + Parse a complex type from a JSON string or return the original value if it's not JSON. + + Args: + 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 the original value if not JSON. + + Raises: + 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 = json.loads(value) + except json.JSONDecodeError: + 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 + + +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 + + """ + type_name = get_type_name(param.annotation) + + 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) + + 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 diff --git a/getstream/cli/video.py b/getstream/cli/video.py new file mode 100644 index 0000000..77eeff8 --- /dev/null +++ b/getstream/cli/video.py @@ -0,0 +1,274 @@ +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.video.client import VideoClient +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. + + 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") + @pass_client + 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 + 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 == "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)(**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 + 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. + + 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:Stream,app_name:str, **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) + + sig = inspect.signature(method) + for param_name, param in sig.parameters.items(): + if param_name == "self": + continue + add_option_from_arg(cmd, param_name, param) + + return cmd + + +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)) + else: + click.echo(json.dumps(result, indent=2, default=str)) + + +# 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}, + "block-user": {"method": Call.block_user}, + "unblock-user": {"method": Call.unblock_user}, + "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 +} + +# 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 the commands +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 +@click.group() +def call(): + """Commands for specific calls""" + pass + + +for cmd in call_cmds: + if cmd is not None: + call.add_command(cmd) + else: + print("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: + if cmd is not None: + video.add_command(cmd) + else: + print("Warning: Skipping None command") + + +@click.command() +@click.option("--rtmp-user-id", default=f"{uuid.uuid4()}") +@pass_client +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, + ), + ) + 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"React call link: https://pronto.getstream.io/join/{call.data.call.id}?api_key={client.api_key}&token={viewer_call_token}" + ) + 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/models/__init__.py b/getstream/models/__init__.py index 59d34a1..9f0b7f9 100644 --- a/getstream/models/__init__.py +++ b/getstream/models/__init__.py @@ -583,6 +583,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")) @@ -597,6 +703,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( @@ -730,6 +871,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")) diff --git a/getstream/stream.py b/getstream/stream.py index ab7f74e..85238d7 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 9c4b027..8125fd2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,14 +1,14 @@ -# 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" -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]] @@ -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" @@ -57,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] @@ -97,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]] @@ -221,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] @@ -235,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]] @@ -262,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] @@ -318,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] @@ -395,6 +406,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" @@ -485,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" @@ -560,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]] @@ -586,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] @@ -607,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 = "a4506a52588ea3e61f9e094104b901834abc57b76a2e4f49d996b4032eb66822" +content-hash = "40e10402f29baab472794ed17303a5b50fc6df96a05575ad58d0fd542991136e" diff --git a/pyproject.toml b/pyproject.toml index 2685499..5354648 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" @@ -21,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" @@ -32,3 +34,9 @@ build-backend = "poetry.core.masonry.api" [tool.ruff.lint] ignore = ["F405", "F403"] + +[tool.poetry.scripts] +stream-cli = "getstream.cli:main" + +[tool.poetry.plugins."getstream.completion"] +stream-cli = "getstream.cli.completion:completion" \ No newline at end of file diff --git a/tests/fixtures.py b/tests/fixtures.py index c9c2501..8365e34 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,13 +1,34 @@ 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( api_key=os.environ.get("STREAM_API_KEY"), diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..907d3e1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,363 @@ +import inspect +from getstream import cli as stream_cli +import pytest +from typing import Optional, List, Dict, Union +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 # noqa + + +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" + + # Mock the Stream class to return our mocked client + mocker.patch("getstream.cli.Stream", return_value=mock_stream) + + result = cli_runner.invoke( + stream_cli.cli, ["create-token", "--user-id", "your_user_id"] + ) + + # 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 "mocked_token" in result.output + 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]" + + +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 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 + @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 + + +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 + mock_response = mocker.Mock() + mock_response.data.to_dict.return_value = { + "call": { + "cid": "default:123456", + "created_at": "2023-07-03T12:00:00Z", + "updated_at": "2023-07-03T12:00:00Z", + "members_limit": 10, + } + } + 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"}', + ) + + 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}") + print(f"Exception: {result.exception}") + + assert result.exit_code == 0 + assert '"cid": "default:123456"' in result.output + assert '"members_limit": 10' in result.output + assert '"mocked": "json"' in result.output + + 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 + + +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( + 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" + + +def test_cli_mute_all(mocker, cli_runner): # noqa + 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, + ) + + +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 + """ + 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): # noqa + """ + 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): # 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"}' + """ + mock_setup(mocker) + result = cli_runner.invoke( + stream_cli.cli, + [ + "video", + "call", + "send-call-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): # 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}}' + """ + 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