Skip to content
Snippets Groups Projects
Verified Commit c2a421d0 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Initial commit

parents
Branches
No related tags found
No related merge requests found
A command line interface to interact with Funkwhale servers.
# Installation
This cli requires python 3.6 or greater:
git clone https://dev.funkwhale.audio/funkwhale/cli.git
cd cli
pip install .
# Usage
``funkwhale --help``
import aiohttp
from . import exceptions
from . import logs
from . import schemas
from . import settings
def get_session_kwargs():
headers = {"User-Agent": settings.USER_AGENT}
return {
"timeout": aiohttp.ClientTimeout(total=settings.TIMEOUT),
"headers": headers,
}
def get_session():
kwargs = get_session_kwargs()
return aiohttp.ClientSession(**kwargs)
async def fetch_nodeinfo(session, domain, protocol="https"):
nodeinfo = await get_well_known_data(session, domain=domain, protocol=protocol)
data = await get_nodeinfo(session, nodeinfo)
return data
async def get_well_known_data(session, domain, protocol="https"):
url = f"https://{domain}/.well-known/nodeinfo"
response = await session.get(url)
return await response.json()
async def get_nodeinfo(session, nodeinfo):
for link in nodeinfo.get("links", []):
if link["rel"] == "http://nodeinfo.diaspora.software/ns/schema/2.0":
response = await session.get(link["href"])
return await response.json()
raise exceptions.NoNodeInfo()
def clean_nodeinfo(data):
schema = schemas.NodeInfo2Schema()
result = schema.load(data)
return result.data
import asyncio
import aiohttp
import click
import click_log
import functools
import logging
import urllib.parse
import json
from . import api
from . import exceptions
from . import logs
click_log.basic_config(logs.logger)
def URL(v):
v = str(v) if v else None
parsed = urllib.parse.urlparse(v)
if parsed.scheme not in ["http", "https"] or not parsed.netloc:
raise ValueError("{} is not a valid url".format(v))
if not v.endswith("/"):
v = v + "/"
return v
def async_command(f):
def wrapper(*args, **kwargs):
loop = asyncio.get_event_loop()
try:
return loop.run_until_complete(f(*args, **kwargs))
except (exceptions.FunkwhaleError, aiohttp.client_exceptions.ClientError) as e:
raise click.ClickException(str(e))
else:
raise
return functools.update_wrapper(wrapper, f)
@click.group()
@click.option("-H", "--url", envvar="FUNKWHALE_SERVER_URL", type=URL)
@click_log.simple_verbosity_option(logs.logger, expose_value=True)
@click.pass_context
def cli(ctx, url, verbosity):
ctx.ensure_object(dict)
ctx.obj["SERVER_URL"] = url
parsed = urllib.parse.urlparse(url)
ctx.obj["SERVER_NETLOC"] = parsed.netloc
ctx.obj["SERVER_PROTOCOL"] = parsed.scheme
@cli.group()
@click.pass_context
def server(ctx):
ctx.ensure_object(dict)
@server.command()
@click.option("--raw", is_flag=True)
@click.pass_context
@async_command
async def info(ctx, raw):
async with api.get_session() as session:
nodeinfo = await api.fetch_nodeinfo(
session,
domain=ctx.obj["SERVER_NETLOC"],
protocol=ctx.obj["SERVER_PROTOCOL"],
)
if raw:
click.echo(json.dumps(nodeinfo, sort_keys=True, indent=4))
return
click.echo("\n")
click.echo("General")
click.echo("-------")
click.echo("Url: {}".format(ctx.obj["SERVER_URL"]))
click.echo("Name: {}".format(nodeinfo["metadata"]["nodeName"]))
click.echo(
"Short description: {}".format(nodeinfo["metadata"]["shortDescription"])
)
click.echo("\n")
click.echo("Software")
click.echo("----------")
click.echo("Software name: {}".format(nodeinfo["software"]["name"]))
click.echo("Version: {}".format(nodeinfo["software"]["version"]))
click.echo("\n")
click.echo("Configuration")
click.echo("---------------")
click.echo(
"Registrations: {}".format(
"open" if nodeinfo["openRegistrations"] else "closed"
)
)
if __name__ == "__main__":
cli()
class FunkwhaleError(Exception):
pass
class NoNodeInfo(FunkwhaleError):
pass
import logging
logger = logging.getLogger(__name__)
import marshmallow
import semver
import re
class VersionField(marshmallow.fields.Str):
def deserialize(self, value, *args, **kwargs):
value = super().deserialize(value, *args, **kwargs)
try:
return semver.parse(value)
except ValueError:
# funkwhale does not always include the patch version, so we add the 0 ourself and
# try again
try:
v_regex = r"(\d+\.\d+)"
match = re.match(v_regex, value)
if match and match[0]:
new_version = f"{match[0]}.0"
return semver.parse(value.replace(match[0], new_version, 1))
raise ValueError()
except (ValueError, IndexError):
raise marshmallow.ValidationError(
f"{value} is not a semver version number"
)
return value
class SoftwareSchema(marshmallow.Schema):
name = marshmallow.fields.String(
required=True, validate=[marshmallow.validate.OneOf(["funkwhale", "Funkwhale"])]
)
version = VersionField(required=True)
"""
"openRegistrations": False,
"usage": {"users": {"total": 78}},
"metadata": {
"private": False,
"nodeName": "Funkwhale 101",
"library": {
"federationEnabled": True,
"federationNeedsApproval": True,
"anonymousCanListen": True,
"tracks": {"total": 98552},
"artists": {"total": 9831},
"albums": {"total": 10872},
"music": {"hours": 7650.678055555555},
},
"usage": {
"favorites": {"tracks": {"total": 1683}},
"listenings": {"total": 50294},
},
},
}
"""
class StatisticsSchema(marshmallow.Schema):
total = marshmallow.fields.Integer(required=True)
class UsageStatisticsSchema(StatisticsSchema):
activeHalfyear = marshmallow.fields.Integer(required=False)
activeMonth = marshmallow.fields.Integer(required=False)
class UsageSchema(marshmallow.Schema):
users = marshmallow.fields.Nested(UsageStatisticsSchema, required=True)
class MusicSchema(marshmallow.Schema):
hours = marshmallow.fields.Integer(required=False)
class LibraryMetadataSchema(marshmallow.Schema):
anonymousCanListen = marshmallow.fields.Boolean(required=True)
federationEnabled = marshmallow.fields.Boolean(required=True)
tracks = marshmallow.fields.Nested(StatisticsSchema, required=False)
albums = marshmallow.fields.Nested(StatisticsSchema, required=False)
artists = marshmallow.fields.Nested(StatisticsSchema, required=False)
music = marshmallow.fields.Nested(MusicSchema, required=False)
class MetadataUsageSchema(marshmallow.Schema):
listenings = marshmallow.fields.Nested(StatisticsSchema, required=False)
class MetadataSchema(marshmallow.Schema):
nodeName = marshmallow.fields.String(required=True)
private = marshmallow.fields.Boolean(required=True)
library = marshmallow.fields.Nested(LibraryMetadataSchema, required=True)
usage = marshmallow.fields.Nested(MetadataUsageSchema, required=True)
class NodeInfo2Schema(marshmallow.Schema):
software = marshmallow.fields.Nested(SoftwareSchema, required=True)
openRegistrations = marshmallow.fields.Boolean(required=True)
usage = marshmallow.fields.Nested(UsageSchema, required=True)
metadata = marshmallow.fields.Nested(MetadataSchema, required=True)
class Meta:
strict = True
USER_AGENT = "funkwhale/cli"
TIMEOUT = 5
[metadata]
name = funkwhale-cli
description = "XXX"
version = 0.1.dev0
author = Eliot Berriot
author_email = contact@eliotberriot.com
url = https://dev.funkwhale.audio/funkwhale/cli
long_description = file: README.md
license = AGPL3
keywords = cli
classifiers =
Development Status :: 3 - Alpha
License :: OSI Approved :: AGPL
Natural Language :: English
Programming Language :: Python :: 3.6
[options]
zip_safe = True
include_package_data = True
packages = find:
install_requires =
aiofiles
aiohttp
click
click-log
marshmallow
semver
[options.entry_points]
console_scripts =
funkwhale = funkwhale_cli.cli:cli
[options.extras_require]
dev =
aioresponses
asynctest
ipdb
pytest
pytest-mock
pytest-env
[options.packages.find]
exclude =
tests
[bdist_wheel]
universal = 1
[tool:pytest]
testpaths = tests
#!/usr/bin/env python
# -*- coding: utf-8 -*-
from setuptools import setup
setup()
import pytest
import aiohttp
from aioresponses import aioresponses
import asynctest
pytest_plugins = "aiohttp.pytest_plugin"
@pytest.fixture
def responses():
with aioresponses() as m:
yield m
@pytest.fixture
async def session(loop):
async with aiohttp.ClientSession() as session:
yield session
@pytest.fixture
async def coroutine_mock():
return asynctest.CoroutineMock
import aiohttp
import marshmallow
import pytest
from funkwhale_cli import api
async def test_fetch_nodeinfo(session, responses):
domain = "test.domain"
well_known_payload = {
"links": [
{
"rel": "http://nodeinfo.diaspora.software/ns/schema/2.0",
"href": "https://test.domain/nodeinfo/2.0/",
}
]
}
payload = {"hello": "world"}
responses.get(
"https://test.domain/.well-known/nodeinfo", payload=well_known_payload
)
responses.get("https://test.domain/nodeinfo/2.0/", payload=payload)
result = await api.fetch_nodeinfo(session, domain)
assert result == payload
def test_clean_nodeinfo():
payload = {
"version": "2.0",
"software": {"name": "funkwhale", "version": "0.18-dev+git.b575999e"},
"openRegistrations": False,
"usage": {"users": {"total": 78, "activeHalfyear": 42, "activeMonth": 23}},
"metadata": {
"private": False,
"nodeName": "Test Domain",
"library": {
"federationEnabled": True,
"federationNeedsApproval": True,
"anonymousCanListen": True,
"tracks": {"total": 98552},
"artists": {"total": 9831},
"albums": {"total": 10872},
"music": {"hours": 7650.678055555555},
},
"usage": {
"favorites": {"tracks": {"total": 1683}},
"listenings": {"total": 50294},
},
},
}
expected = {
"software": {
"name": "funkwhale",
"version": {
"major": 0,
"minor": 18,
"patch": 0,
"prerelease": "dev",
"build": "git.b575999e",
},
},
"openRegistrations": False,
"usage": {"users": {"total": 78, "activeHalfyear": 42, "activeMonth": 23}},
"metadata": {
"private": False,
"nodeName": "Test Domain",
"library": {
"federationEnabled": True,
"anonymousCanListen": True,
"tracks": {"total": 98552},
"artists": {"total": 9831},
"albums": {"total": 10872},
"music": {"hours": 7650},
},
"usage": {"listenings": {"total": 50294}},
},
}
result = api.clean_nodeinfo(payload)
assert result == expected
def test_clean_nodeinfo_raises_on_validation_failure():
payload = {}
with pytest.raises(marshmallow.ValidationError):
api.clean_nodeinfo({})
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment