Write a function that accepts any number of positional arguments and any number of keyword arguments, and print what it received.
A parameter with a single star in front of it collects any extra positional arguments into a tuple. A parameter with a double star in front of it collects any extra keyword arguments into a dictionary.
def collect(*args, **kwargs):
print('args:', args, type(args).__name__)
print('kwargs:', kwargs, type(kwargs).__name__)
print('first arg:', args[0] if args else None)
print('key:', kwargs.get('key'))
collect(1, 2, 3, key='value', other=42)
The output shows the arguments as the tuple (1, 2, 3) and the keyword arguments as a dictionary with the key and other keys inside it. The types matter as much as the values here. The extra positional arguments arrive as a tuple because positional arguments are ordered, and the extra keyword arguments arrive as a dictionary because keyword arguments are named.
There are two rules about the order of the parameters. The *args and **kwargs parameters must come last in the signature, after any ordinary parameters. But a bare * can also be used on its own, and that is the cleanest way to mark the parameters after it as keyword-only, so that a caller cannot pass one positionally by mistake.
The same syntax is used in the other direction when you call a function. A single star unpacks an iterable into positional arguments, and a double star unpacks a dictionary into keyword arguments.
collect(*[1, 2, 3], **{'key': 'value', 'other': 42})
That call is exactly the same as the literal call above it. The practical use of this is forwarding, where a wrapper function accepts *args and **kwargs and passes them straight through to another function. That is how decorators and framework hooks are usually written.