Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
[submodule "matrix-doc"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a git submodule is the right way to approach including the documentation for testing, but I don't know what the "right way" is off the top of my head.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What don't you like about sub-moduling?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it fairly easy for someone developing to run tests but quite difficult if you've just installed a release version and want to run the tests.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

submodules are not fetched automatically you need to ask git to do that, so if you haven't pull matrix-doc it just runs tests without providing you with spec-coverage.

On the other-hand if we move towards using the matrix-doc to also create / respond to mock requests (we don't currently) than indeed you would need matrix-doc to run tests.

path = matrix-doc
url = https://github.com/matrix-org/matrix-doc.git
1 change: 1 addition & 0 deletions matrix-doc
Submodule matrix-doc added at d643b6
15 changes: 5 additions & 10 deletions matrix_client/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,20 +97,15 @@ def validate_certificate(self, valid):
self.validate_cert = valid
return

def register(self, login_type, **kwargs):
def register(self, content={}, query_params={}):
"""Performs /register.

Args:
login_type(str): The value for the 'type' key.
**kwargs: Additional key/values to add to the JSON submitted.
content(dict): The request payload. Should include "type" such as "m.login.password" for all non-guest registrations.
query_params(dict): The query params for the request. Specify "kind": "guest" to register a guest account.
"""
content = {
"type": login_type
}
for key in kwargs:
content[key] = kwargs[key]

return self._send("POST", "/register", content, api_path=MATRIX_V2_API_PATH)
return self._send("POST", "/register", content=content, query_params=query_params, api_path=MATRIX_V2_API_PATH)

def login(self, login_type, **kwargs):
"""Perform /login.
Expand DownExpand Up@@ -504,7 +499,7 @@ def create_filter(self, user_id, filter_params):
api_path=MATRIX_V2_API_PATH)

def _send(self, method, path, content=None, query_params={}, headers={},
api_path="/_matrix/client/api/v1"):
api_path=MATRIX_V2_API_PATH):
method = method.upper()
if method not in ["GET", "PUT", "DELETE", "POST"]:
raise MatrixError("Unsupported HTTP method: %s" % method)
Expand Down
19 changes: 18 additions & 1 deletion matrix_client/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,23 @@ def set_sync_token(self, token):
def set_user_id(self, user_id):
self.user_id = user_id

def register_as_guest(self):
""" Register a guest account on this HS.
Note: HS must have guest registration enabled.
Returns:
str: Access Token
Raises:
MatrixRequestError
"""
response = self.api.register(query_params={'kind': 'guest'})
self.user_id = response["user_id"]
self.token = response["access_token"]
self.hs = response["home_server"]
self.api.token = self.token
self.sync_filter = '{ "room": { "timeline" : { "limit" : 20 } } }'
self._sync()
return self.token

def register_with_password(self, username, password, limit=1):
""" Register for a new account on this HS.

Expand All@@ -127,7 +144,7 @@ def register_with_password(self, username, password, limit=1):
MatrixRequestError
"""
response = self.api.register(
"m.login.password", user=username, password=password
{'type': "m.login.password", 'user': username, 'password': password}
)
self.user_id = response["user_id"]
self.token = response["access_token"]
Expand Down
11 changes: 7 additions & 4 deletions test/api_test.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
import responses
import pytest
responses = pytest.responses_with_api_guide
if not responses:
import responses
from matrix_client import client


class TestTagsApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
Expand DownExpand Up@@ -67,14 +69,15 @@ class TestUnbanApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
room_id = "#foo:matrix.org"

@responses.activate
def test_unban(self):
unban_url = "http://example.com" \
"/_matrix/client/api/v1/rooms/#foo:matrix.org/unban"
"/_matrix/client/r0/rooms/#foo:matrix.org/unban"
body = '{"user_id": "'+ self.user_id + '"}'
responses.add(responses.POST, unban_url, body=body)
self.cli.api.unban_user(self.room_id, self.user_id)
req = responses.calls[0].request
assert req.url == unban_url
assert req.method == 'POST'

1 change: 1 addition & 0 deletions test/conftest.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
pytest_plugins = "matrix_spec_coverage_plugin"
94 changes: 94 additions & 0 deletions test/matrix_spec_coverage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
import sys
import re
import yaml
from responses import RequestsMock

INTERPOLATIONS = [
("%CLIENT_MAJOR_VERSION%", "r0")

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed the spec only lists %CLIENT_MAJOR_VERSION% for all it's docs, potentially this is fail-prone where api/v1 and api/v2 actually differ in Synapse, probably needs to have an issue opened on matrix-doc.

]

def interpolate_str(s):
for interpolation in INTERPOLATIONS:
s = s.replace(interpolation[0], interpolation[1])
return s

def endpoint_to_regex(s):
# TODO sub by with more specific REGEXes per type
# e.g. roomId, eventId, userId
return re.sub('\{[a-zA-Z]+\}', '[a-zA-Z!\.:-@#]+', s)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bad, requests, or some other library should provide a way to directly assert whether a request matches e.g. /room_id/{roomId} I haven't really looked into it, but regexing is not the preferred way for sure.


MISSING_BASE_PATH = "Not a valid API Base Path: "
MISSING_ENDPOINT = "Not a valid API Endpoint: "
MISSING_METHOD = "Not a valid API Method: "

class ApiGuide:

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of this class-name - but I'd prefer not to have something very long like MatrixApiCoverageStatGatherer, though maybe that's better than this?

def __init__(self, hostname="http://example.com"):
self.hostname = hostname
self.endpoints = {}
self.called = []
self.missing = []
self.total_endpoints = 0

def setup_from_files(self, files):
for file in files:
with open(file) as rfile:
definitions = yaml.load(rfile)
base_path = definitions['basePath']
resolved_base_path = interpolate_str(base_path)
if resolved_base_path not in self.endpoints:
self.endpoints[resolved_base_path] = {}
regex_paths = { endpoint_to_regex(k): v for k,v in definitions['paths'].items() }
self.endpoints[resolved_base_path].update(regex_paths)
endpoints_added = sum(len(v) for v in definitions['paths'].values())
self.total_endpoints += endpoints_added

def process_request(self, request):
full_path_url = request.url
method = request.method
body = request.body
for base_path in self.endpoints.keys():
if base_path in full_path_url:
path_url = full_path_url.replace(base_path, '')
path_url = path_url.replace(self.hostname, '')
break
else:
self.add_called_missing(MISSING_BASE_PATH, request)
return
endpoints = self.endpoints[base_path]
for endpoint in endpoints.keys():
if re.fullmatch(endpoint, path_url):
break
else:
self.add_called_missing(MISSING_ENDPOINT, request)
return
endpoint_def = endpoints[endpoint]
try:
endpoint_def[method.lower()]
self.add_called(base_path, endpoint, method, body)
except KeyError:
self.add_called_missing(MISSING_METHOD, request)


def add_called(self, base_path, endpoint, method, body):
self.called.append((base_path, endpoint, method, body))

def add_called_missing(self, error,request):
self.missing.append((error, request.url, request.method, request.body))

def print_summary(self):
print("Accessed: %i out of %i endpoints. %0.2f%% Coverage." %
(len(self.called), self.total_endpoints, len(self.called)*100 / self.total_endpoints)
)
if self.missing:
missing_summary = "\n".join(m[0] + ", ".join(m[1:-1]) for m in self.missing)
raise AssertionError("The following invalid API Requests were made:\n" +
missing_summary)

class RequestsMockWithApiGuide(RequestsMock):
def __init__(self, api_guide, assert_all_requests_are_fired=True):
self.api_guide = api_guide
super().__init__(assert_all_requests_are_fired)

def _on_request(self, adapter, request, **kwargs):
self.api_guide.process_request(request)
return super()._on_request(adapter, request, **kwargs)
32 changes: 32 additions & 0 deletions test/matrix_spec_coverage_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import _pytest
import pytest
from _pytest._pluggy import HookspecMarker
from matrix_spec_coverage import ApiGuide, RequestsMockWithApiGuide

hookspec = HookspecMarker("pytest")

# We use this to print api_guide coverage stats
# after pytest has finished running
def pytest_terminal_summary(terminalreporter, exitstatus):
if pytest.responses_with_api_guide:
guide = pytest.responses_with_api_guide.api_guide
guide.print_summary()


def build_api_guide():
import os
from glob import glob
DOC_FOLDER = "../matrix-doc/api/client-server/"
api_files = glob(os.path.join(DOC_FOLDER, '*.yaml'))
if not api_files:
return
guide = ApiGuide()
guide.setup_from_files(API_FILES)
return guide

# Load api_guide stats into the pytest namespace so
# that we can print a the stats on terminal summary
@hookspec(historic=True)
def pytest_namespace():
guide = build_api_guide()
return { 'responses_with_api_guide': RequestsMockWithApiGuide(guide) }
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Spec Coverage via. matrix-doc in pytest by pik · Pull Request #125 · matrix-org/matrix-python-sdk · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
[submodule "matrix-doc"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a git submodule is the right way to approach including the documentation for testing, but I don't know what the "right way" is off the top of my head.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What don't you like about sub-moduling?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it fairly easy for someone developing to run tests but quite difficult if you've just installed a release version and want to run the tests.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

submodules are not fetched automatically you need to ask git to do that, so if you haven't pull matrix-doc it just runs tests without providing you with spec-coverage.

On the other-hand if we move towards using the matrix-doc to also create / respond to mock requests (we don't currently) than indeed you would need matrix-doc to run tests.

path = matrix-doc
url = https://github.com/matrix-org/matrix-doc.git
1 change: 1 addition & 0 deletions matrix-doc
Submodule matrix-doc added at d643b6
15 changes: 5 additions & 10 deletions matrix_client/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,20 +97,15 @@ def validate_certificate(self, valid):
self.validate_cert = valid
return

def register(self, login_type, **kwargs):
def register(self, content={}, query_params={}):
"""Performs /register.

Args:
login_type(str): The value for the 'type' key.
**kwargs: Additional key/values to add to the JSON submitted.
content(dict): The request payload. Should include "type" such as "m.login.password" for all non-guest registrations.
query_params(dict): The query params for the request. Specify "kind": "guest" to register a guest account.
"""
content = {
"type": login_type
}
for key in kwargs:
content[key] = kwargs[key]

return self._send("POST", "/register", content, api_path=MATRIX_V2_API_PATH)
return self._send("POST", "/register", content=content, query_params=query_params, api_path=MATRIX_V2_API_PATH)

def login(self, login_type, **kwargs):
"""Perform /login.
Expand DownExpand Up@@ -504,7 +499,7 @@ def create_filter(self, user_id, filter_params):
api_path=MATRIX_V2_API_PATH)

def _send(self, method, path, content=None, query_params={}, headers={},
api_path="/_matrix/client/api/v1"):
api_path=MATRIX_V2_API_PATH):
method = method.upper()
if method not in ["GET", "PUT", "DELETE", "POST"]:
raise MatrixError("Unsupported HTTP method: %s" % method)
Expand Down
19 changes: 18 additions & 1 deletion matrix_client/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,23 @@ def set_sync_token(self, token):
def set_user_id(self, user_id):
self.user_id = user_id

def register_as_guest(self):
""" Register a guest account on this HS.
Note: HS must have guest registration enabled.
Returns:
str: Access Token
Raises:
MatrixRequestError
"""
response = self.api.register(query_params={'kind': 'guest'})
self.user_id = response["user_id"]
self.token = response["access_token"]
self.hs = response["home_server"]
self.api.token = self.token
self.sync_filter = '{ "room": { "timeline" : { "limit" : 20 } } }'
self._sync()
return self.token

def register_with_password(self, username, password, limit=1):
""" Register for a new account on this HS.

Expand All@@ -127,7 +144,7 @@ def register_with_password(self, username, password, limit=1):
MatrixRequestError
"""
response = self.api.register(
"m.login.password", user=username, password=password
{'type': "m.login.password", 'user': username, 'password': password}
)
self.user_id = response["user_id"]
self.token = response["access_token"]
Expand Down
11 changes: 7 additions & 4 deletions test/api_test.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
import responses
import pytest
responses = pytest.responses_with_api_guide
if not responses:
import responses
from matrix_client import client


class TestTagsApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
Expand DownExpand Up@@ -67,14 +69,15 @@ class TestUnbanApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
room_id = "#foo:matrix.org"

@responses.activate
def test_unban(self):
unban_url = "http://example.com" \
"/_matrix/client/api/v1/rooms/#foo:matrix.org/unban"
"/_matrix/client/r0/rooms/#foo:matrix.org/unban"
body = '{"user_id": "'+ self.user_id + '"}'
responses.add(responses.POST, unban_url, body=body)
self.cli.api.unban_user(self.room_id, self.user_id)
req = responses.calls[0].request
assert req.url == unban_url
assert req.method == 'POST'

1 change: 1 addition & 0 deletions test/conftest.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
pytest_plugins = "matrix_spec_coverage_plugin"
94 changes: 94 additions & 0 deletions test/matrix_spec_coverage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
import sys
import re
import yaml
from responses import RequestsMock

INTERPOLATIONS = [
("%CLIENT_MAJOR_VERSION%", "r0")

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed the spec only lists %CLIENT_MAJOR_VERSION% for all it's docs, potentially this is fail-prone where api/v1 and api/v2 actually differ in Synapse, probably needs to have an issue opened on matrix-doc.

]

def interpolate_str(s):
for interpolation in INTERPOLATIONS:
s = s.replace(interpolation[0], interpolation[1])
return s

def endpoint_to_regex(s):
# TODO sub by with more specific REGEXes per type
# e.g. roomId, eventId, userId
return re.sub('\{[a-zA-Z]+\}', '[a-zA-Z!\.:-@#]+', s)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bad, requests, or some other library should provide a way to directly assert whether a request matches e.g. /room_id/{roomId} I haven't really looked into it, but regexing is not the preferred way for sure.


MISSING_BASE_PATH = "Not a valid API Base Path: "
MISSING_ENDPOINT = "Not a valid API Endpoint: "
MISSING_METHOD = "Not a valid API Method: "

class ApiGuide:

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of this class-name - but I'd prefer not to have something very long like MatrixApiCoverageStatGatherer, though maybe that's better than this?

def __init__(self, hostname="http://example.com"):
self.hostname = hostname
self.endpoints = {}
self.called = []
self.missing = []
self.total_endpoints = 0

def setup_from_files(self, files):
for file in files:
with open(file) as rfile:
definitions = yaml.load(rfile)
base_path = definitions['basePath']
resolved_base_path = interpolate_str(base_path)
if resolved_base_path not in self.endpoints:
self.endpoints[resolved_base_path] = {}
regex_paths = { endpoint_to_regex(k): v for k,v in definitions['paths'].items() }
self.endpoints[resolved_base_path].update(regex_paths)
endpoints_added = sum(len(v) for v in definitions['paths'].values())
self.total_endpoints += endpoints_added

def process_request(self, request):
full_path_url = request.url
method = request.method
body = request.body
for base_path in self.endpoints.keys():
if base_path in full_path_url:
path_url = full_path_url.replace(base_path, '')
path_url = path_url.replace(self.hostname, '')
break
else:
self.add_called_missing(MISSING_BASE_PATH, request)
return
endpoints = self.endpoints[base_path]
for endpoint in endpoints.keys():
if re.fullmatch(endpoint, path_url):
break
else:
self.add_called_missing(MISSING_ENDPOINT, request)
return
endpoint_def = endpoints[endpoint]
try:
endpoint_def[method.lower()]
self.add_called(base_path, endpoint, method, body)
except KeyError:
self.add_called_missing(MISSING_METHOD, request)


def add_called(self, base_path, endpoint, method, body):
self.called.append((base_path, endpoint, method, body))

def add_called_missing(self, error,request):
self.missing.append((error, request.url, request.method, request.body))

def print_summary(self):
print("Accessed: %i out of %i endpoints. %0.2f%% Coverage." %
(len(self.called), self.total_endpoints, len(self.called)*100 / self.total_endpoints)
)
if self.missing:
missing_summary = "\n".join(m[0] + ", ".join(m[1:-1]) for m in self.missing)
raise AssertionError("The following invalid API Requests were made:\n" +
missing_summary)

class RequestsMockWithApiGuide(RequestsMock):
def __init__(self, api_guide, assert_all_requests_are_fired=True):
self.api_guide = api_guide
super().__init__(assert_all_requests_are_fired)

def _on_request(self, adapter, request, **kwargs):
self.api_guide.process_request(request)
return super()._on_request(adapter, request, **kwargs)
32 changes: 32 additions & 0 deletions test/matrix_spec_coverage_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import _pytest
import pytest
from _pytest._pluggy import HookspecMarker
from matrix_spec_coverage import ApiGuide, RequestsMockWithApiGuide

hookspec = HookspecMarker("pytest")

# We use this to print api_guide coverage stats
# after pytest has finished running
def pytest_terminal_summary(terminalreporter, exitstatus):
if pytest.responses_with_api_guide:
guide = pytest.responses_with_api_guide.api_guide
guide.print_summary()


def build_api_guide():
import os
from glob import glob
DOC_FOLDER = "../matrix-doc/api/client-server/"
api_files = glob(os.path.join(DOC_FOLDER, '*.yaml'))
if not api_files:
return
guide = ApiGuide()
guide.setup_from_files(API_FILES)
return guide

# Load api_guide stats into the pytest namespace so
# that we can print a the stats on terminal summary
@hookspec(historic=True)
def pytest_namespace():
guide = build_api_guide()
return { 'responses_with_api_guide': RequestsMockWithApiGuide(guide) }
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Spec Coverage via. matrix-doc in pytest by pik · Pull Request #125 · matrix-org/matrix-python-sdk · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
[submodule "matrix-doc"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a git submodule is the right way to approach including the documentation for testing, but I don't know what the "right way" is off the top of my head.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What don't you like about sub-moduling?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it fairly easy for someone developing to run tests but quite difficult if you've just installed a release version and want to run the tests.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

submodules are not fetched automatically you need to ask git to do that, so if you haven't pull matrix-doc it just runs tests without providing you with spec-coverage.

On the other-hand if we move towards using the matrix-doc to also create / respond to mock requests (we don't currently) than indeed you would need matrix-doc to run tests.

path = matrix-doc
url = https://github.com/matrix-org/matrix-doc.git
1 change: 1 addition & 0 deletions matrix-doc
Submodule matrix-doc added at d643b6
15 changes: 5 additions & 10 deletions matrix_client/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,20 +97,15 @@ def validate_certificate(self, valid):
self.validate_cert = valid
return

def register(self, login_type, **kwargs):
def register(self, content={}, query_params={}):
"""Performs /register.

Args:
login_type(str): The value for the 'type' key.
**kwargs: Additional key/values to add to the JSON submitted.
content(dict): The request payload. Should include "type" such as "m.login.password" for all non-guest registrations.
query_params(dict): The query params for the request. Specify "kind": "guest" to register a guest account.
"""
content = {
"type": login_type
}
for key in kwargs:
content[key] = kwargs[key]

return self._send("POST", "/register", content, api_path=MATRIX_V2_API_PATH)
return self._send("POST", "/register", content=content, query_params=query_params, api_path=MATRIX_V2_API_PATH)

def login(self, login_type, **kwargs):
"""Perform /login.
Expand DownExpand Up@@ -504,7 +499,7 @@ def create_filter(self, user_id, filter_params):
api_path=MATRIX_V2_API_PATH)

def _send(self, method, path, content=None, query_params={}, headers={},
api_path="/_matrix/client/api/v1"):
api_path=MATRIX_V2_API_PATH):
method = method.upper()
if method not in ["GET", "PUT", "DELETE", "POST"]:
raise MatrixError("Unsupported HTTP method: %s" % method)
Expand Down
19 changes: 18 additions & 1 deletion matrix_client/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,23 @@ def set_sync_token(self, token):
def set_user_id(self, user_id):
self.user_id = user_id

def register_as_guest(self):
""" Register a guest account on this HS.
Note: HS must have guest registration enabled.
Returns:
str: Access Token
Raises:
MatrixRequestError
"""
response = self.api.register(query_params={'kind': 'guest'})
self.user_id = response["user_id"]
self.token = response["access_token"]
self.hs = response["home_server"]
self.api.token = self.token
self.sync_filter = '{ "room": { "timeline" : { "limit" : 20 } } }'
self._sync()
return self.token

def register_with_password(self, username, password, limit=1):
""" Register for a new account on this HS.

Expand All@@ -127,7 +144,7 @@ def register_with_password(self, username, password, limit=1):
MatrixRequestError
"""
response = self.api.register(
"m.login.password", user=username, password=password
{'type': "m.login.password", 'user': username, 'password': password}
)
self.user_id = response["user_id"]
self.token = response["access_token"]
Expand Down
11 changes: 7 additions & 4 deletions test/api_test.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
import responses
import pytest
responses = pytest.responses_with_api_guide
if not responses:
import responses
from matrix_client import client


class TestTagsApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
Expand DownExpand Up@@ -67,14 +69,15 @@ class TestUnbanApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
room_id = "#foo:matrix.org"

@responses.activate
def test_unban(self):
unban_url = "http://example.com" \
"/_matrix/client/api/v1/rooms/#foo:matrix.org/unban"
"/_matrix/client/r0/rooms/#foo:matrix.org/unban"
body = '{"user_id": "'+ self.user_id + '"}'
responses.add(responses.POST, unban_url, body=body)
self.cli.api.unban_user(self.room_id, self.user_id)
req = responses.calls[0].request
assert req.url == unban_url
assert req.method == 'POST'

1 change: 1 addition & 0 deletions test/conftest.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
pytest_plugins = "matrix_spec_coverage_plugin"
94 changes: 94 additions & 0 deletions test/matrix_spec_coverage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
import sys
import re
import yaml
from responses import RequestsMock

INTERPOLATIONS = [
("%CLIENT_MAJOR_VERSION%", "r0")

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed the spec only lists %CLIENT_MAJOR_VERSION% for all it's docs, potentially this is fail-prone where api/v1 and api/v2 actually differ in Synapse, probably needs to have an issue opened on matrix-doc.

]

def interpolate_str(s):
for interpolation in INTERPOLATIONS:
s = s.replace(interpolation[0], interpolation[1])
return s

def endpoint_to_regex(s):
# TODO sub by with more specific REGEXes per type
# e.g. roomId, eventId, userId
return re.sub('\{[a-zA-Z]+\}', '[a-zA-Z!\.:-@#]+', s)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bad, requests, or some other library should provide a way to directly assert whether a request matches e.g. /room_id/{roomId} I haven't really looked into it, but regexing is not the preferred way for sure.


MISSING_BASE_PATH = "Not a valid API Base Path: "
MISSING_ENDPOINT = "Not a valid API Endpoint: "
MISSING_METHOD = "Not a valid API Method: "

class ApiGuide:

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of this class-name - but I'd prefer not to have something very long like MatrixApiCoverageStatGatherer, though maybe that's better than this?

def __init__(self, hostname="http://example.com"):
self.hostname = hostname
self.endpoints = {}
self.called = []
self.missing = []
self.total_endpoints = 0

def setup_from_files(self, files):
for file in files:
with open(file) as rfile:
definitions = yaml.load(rfile)
base_path = definitions['basePath']
resolved_base_path = interpolate_str(base_path)
if resolved_base_path not in self.endpoints:
self.endpoints[resolved_base_path] = {}
regex_paths = { endpoint_to_regex(k): v for k,v in definitions['paths'].items() }
self.endpoints[resolved_base_path].update(regex_paths)
endpoints_added = sum(len(v) for v in definitions['paths'].values())
self.total_endpoints += endpoints_added

def process_request(self, request):
full_path_url = request.url
method = request.method
body = request.body
for base_path in self.endpoints.keys():
if base_path in full_path_url:
path_url = full_path_url.replace(base_path, '')
path_url = path_url.replace(self.hostname, '')
break
else:
self.add_called_missing(MISSING_BASE_PATH, request)
return
endpoints = self.endpoints[base_path]
for endpoint in endpoints.keys():
if re.fullmatch(endpoint, path_url):
break
else:
self.add_called_missing(MISSING_ENDPOINT, request)
return
endpoint_def = endpoints[endpoint]
try:
endpoint_def[method.lower()]
self.add_called(base_path, endpoint, method, body)
except KeyError:
self.add_called_missing(MISSING_METHOD, request)


def add_called(self, base_path, endpoint, method, body):
self.called.append((base_path, endpoint, method, body))

def add_called_missing(self, error,request):
self.missing.append((error, request.url, request.method, request.body))

def print_summary(self):
print("Accessed: %i out of %i endpoints. %0.2f%% Coverage." %
(len(self.called), self.total_endpoints, len(self.called)*100 / self.total_endpoints)
)
if self.missing:
missing_summary = "\n".join(m[0] + ", ".join(m[1:-1]) for m in self.missing)
raise AssertionError("The following invalid API Requests were made:\n" +
missing_summary)

class RequestsMockWithApiGuide(RequestsMock):
def __init__(self, api_guide, assert_all_requests_are_fired=True):
self.api_guide = api_guide
super().__init__(assert_all_requests_are_fired)

def _on_request(self, adapter, request, **kwargs):
self.api_guide.process_request(request)
return super()._on_request(adapter, request, **kwargs)
32 changes: 32 additions & 0 deletions test/matrix_spec_coverage_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import _pytest
import pytest
from _pytest._pluggy import HookspecMarker
from matrix_spec_coverage import ApiGuide, RequestsMockWithApiGuide

hookspec = HookspecMarker("pytest")

# We use this to print api_guide coverage stats
# after pytest has finished running
def pytest_terminal_summary(terminalreporter, exitstatus):
if pytest.responses_with_api_guide:
guide = pytest.responses_with_api_guide.api_guide
guide.print_summary()


def build_api_guide():
import os
from glob import glob
DOC_FOLDER = "../matrix-doc/api/client-server/"
api_files = glob(os.path.join(DOC_FOLDER, '*.yaml'))
if not api_files:
return
guide = ApiGuide()
guide.setup_from_files(API_FILES)
return guide

# Load api_guide stats into the pytest namespace so
# that we can print a the stats on terminal summary
@hookspec(historic=True)
def pytest_namespace():
guide = build_api_guide()
return { 'responses_with_api_guide': RequestsMockWithApiGuide(guide) }
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Spec Coverage via. matrix-doc in pytest by pik · Pull Request #125 · matrix-org/matrix-python-sdk · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
[submodule "matrix-doc"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a git submodule is the right way to approach including the documentation for testing, but I don't know what the "right way" is off the top of my head.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What don't you like about sub-moduling?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it fairly easy for someone developing to run tests but quite difficult if you've just installed a release version and want to run the tests.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

submodules are not fetched automatically you need to ask git to do that, so if you haven't pull matrix-doc it just runs tests without providing you with spec-coverage.

On the other-hand if we move towards using the matrix-doc to also create / respond to mock requests (we don't currently) than indeed you would need matrix-doc to run tests.

path = matrix-doc
url = https://github.com/matrix-org/matrix-doc.git
1 change: 1 addition & 0 deletions matrix-doc
Submodule matrix-doc added at d643b6
15 changes: 5 additions & 10 deletions matrix_client/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,20 +97,15 @@ def validate_certificate(self, valid):
self.validate_cert = valid
return

def register(self, login_type, **kwargs):
def register(self, content={}, query_params={}):
"""Performs /register.

Args:
login_type(str): The value for the 'type' key.
**kwargs: Additional key/values to add to the JSON submitted.
content(dict): The request payload. Should include "type" such as "m.login.password" for all non-guest registrations.
query_params(dict): The query params for the request. Specify "kind": "guest" to register a guest account.
"""
content = {
"type": login_type
}
for key in kwargs:
content[key] = kwargs[key]

return self._send("POST", "/register", content, api_path=MATRIX_V2_API_PATH)
return self._send("POST", "/register", content=content, query_params=query_params, api_path=MATRIX_V2_API_PATH)

def login(self, login_type, **kwargs):
"""Perform /login.
Expand DownExpand Up@@ -504,7 +499,7 @@ def create_filter(self, user_id, filter_params):
api_path=MATRIX_V2_API_PATH)

def _send(self, method, path, content=None, query_params={}, headers={},
api_path="/_matrix/client/api/v1"):
api_path=MATRIX_V2_API_PATH):
method = method.upper()
if method not in ["GET", "PUT", "DELETE", "POST"]:
raise MatrixError("Unsupported HTTP method: %s" % method)
Expand Down
19 changes: 18 additions & 1 deletion matrix_client/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,23 @@ def set_sync_token(self, token):
def set_user_id(self, user_id):
self.user_id = user_id

def register_as_guest(self):
""" Register a guest account on this HS.
Note: HS must have guest registration enabled.
Returns:
str: Access Token
Raises:
MatrixRequestError
"""
response = self.api.register(query_params={'kind': 'guest'})
self.user_id = response["user_id"]
self.token = response["access_token"]
self.hs = response["home_server"]
self.api.token = self.token
self.sync_filter = '{ "room": { "timeline" : { "limit" : 20 } } }'
self._sync()
return self.token

def register_with_password(self, username, password, limit=1):
""" Register for a new account on this HS.

Expand All@@ -127,7 +144,7 @@ def register_with_password(self, username, password, limit=1):
MatrixRequestError
"""
response = self.api.register(
"m.login.password", user=username, password=password
{'type': "m.login.password", 'user': username, 'password': password}
)
self.user_id = response["user_id"]
self.token = response["access_token"]
Expand Down
11 changes: 7 additions & 4 deletions test/api_test.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
import responses
import pytest
responses = pytest.responses_with_api_guide
if not responses:
import responses
from matrix_client import client


class TestTagsApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
Expand DownExpand Up@@ -67,14 +69,15 @@ class TestUnbanApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
room_id = "#foo:matrix.org"

@responses.activate
def test_unban(self):
unban_url = "http://example.com" \
"/_matrix/client/api/v1/rooms/#foo:matrix.org/unban"
"/_matrix/client/r0/rooms/#foo:matrix.org/unban"
body = '{"user_id": "'+ self.user_id + '"}'
responses.add(responses.POST, unban_url, body=body)
self.cli.api.unban_user(self.room_id, self.user_id)
req = responses.calls[0].request
assert req.url == unban_url
assert req.method == 'POST'

1 change: 1 addition & 0 deletions test/conftest.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
pytest_plugins = "matrix_spec_coverage_plugin"
94 changes: 94 additions & 0 deletions test/matrix_spec_coverage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
import sys
import re
import yaml
from responses import RequestsMock

INTERPOLATIONS = [
("%CLIENT_MAJOR_VERSION%", "r0")

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed the spec only lists %CLIENT_MAJOR_VERSION% for all it's docs, potentially this is fail-prone where api/v1 and api/v2 actually differ in Synapse, probably needs to have an issue opened on matrix-doc.

]

def interpolate_str(s):
for interpolation in INTERPOLATIONS:
s = s.replace(interpolation[0], interpolation[1])
return s

def endpoint_to_regex(s):
# TODO sub by with more specific REGEXes per type
# e.g. roomId, eventId, userId
return re.sub('\{[a-zA-Z]+\}', '[a-zA-Z!\.:-@#]+', s)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bad, requests, or some other library should provide a way to directly assert whether a request matches e.g. /room_id/{roomId} I haven't really looked into it, but regexing is not the preferred way for sure.


MISSING_BASE_PATH = "Not a valid API Base Path: "
MISSING_ENDPOINT = "Not a valid API Endpoint: "
MISSING_METHOD = "Not a valid API Method: "

class ApiGuide:

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of this class-name - but I'd prefer not to have something very long like MatrixApiCoverageStatGatherer, though maybe that's better than this?

def __init__(self, hostname="http://example.com"):
self.hostname = hostname
self.endpoints = {}
self.called = []
self.missing = []
self.total_endpoints = 0

def setup_from_files(self, files):
for file in files:
with open(file) as rfile:
definitions = yaml.load(rfile)
base_path = definitions['basePath']
resolved_base_path = interpolate_str(base_path)
if resolved_base_path not in self.endpoints:
self.endpoints[resolved_base_path] = {}
regex_paths = { endpoint_to_regex(k): v for k,v in definitions['paths'].items() }
self.endpoints[resolved_base_path].update(regex_paths)
endpoints_added = sum(len(v) for v in definitions['paths'].values())
self.total_endpoints += endpoints_added

def process_request(self, request):
full_path_url = request.url
method = request.method
body = request.body
for base_path in self.endpoints.keys():
if base_path in full_path_url:
path_url = full_path_url.replace(base_path, '')
path_url = path_url.replace(self.hostname, '')
break
else:
self.add_called_missing(MISSING_BASE_PATH, request)
return
endpoints = self.endpoints[base_path]
for endpoint in endpoints.keys():
if re.fullmatch(endpoint, path_url):
break
else:
self.add_called_missing(MISSING_ENDPOINT, request)
return
endpoint_def = endpoints[endpoint]
try:
endpoint_def[method.lower()]
self.add_called(base_path, endpoint, method, body)
except KeyError:
self.add_called_missing(MISSING_METHOD, request)


def add_called(self, base_path, endpoint, method, body):
self.called.append((base_path, endpoint, method, body))

def add_called_missing(self, error,request):
self.missing.append((error, request.url, request.method, request.body))

def print_summary(self):
print("Accessed: %i out of %i endpoints. %0.2f%% Coverage." %
(len(self.called), self.total_endpoints, len(self.called)*100 / self.total_endpoints)
)
if self.missing:
missing_summary = "\n".join(m[0] + ", ".join(m[1:-1]) for m in self.missing)
raise AssertionError("The following invalid API Requests were made:\n" +
missing_summary)

class RequestsMockWithApiGuide(RequestsMock):
def __init__(self, api_guide, assert_all_requests_are_fired=True):
self.api_guide = api_guide
super().__init__(assert_all_requests_are_fired)

def _on_request(self, adapter, request, **kwargs):
self.api_guide.process_request(request)
return super()._on_request(adapter, request, **kwargs)
32 changes: 32 additions & 0 deletions test/matrix_spec_coverage_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import _pytest
import pytest
from _pytest._pluggy import HookspecMarker
from matrix_spec_coverage import ApiGuide, RequestsMockWithApiGuide

hookspec = HookspecMarker("pytest")

# We use this to print api_guide coverage stats
# after pytest has finished running
def pytest_terminal_summary(terminalreporter, exitstatus):
if pytest.responses_with_api_guide:
guide = pytest.responses_with_api_guide.api_guide
guide.print_summary()


def build_api_guide():
import os
from glob import glob
DOC_FOLDER = "../matrix-doc/api/client-server/"
api_files = glob(os.path.join(DOC_FOLDER, '*.yaml'))
if not api_files:
return
guide = ApiGuide()
guide.setup_from_files(API_FILES)
return guide

# Load api_guide stats into the pytest namespace so
# that we can print a the stats on terminal summary
@hookspec(historic=True)
def pytest_namespace():
guide = build_api_guide()
return { 'responses_with_api_guide': RequestsMockWithApiGuide(guide) }
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Spec Coverage via. matrix-doc in pytest by pik · Pull Request #125 · matrix-org/matrix-python-sdk · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
[submodule "matrix-doc"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a git submodule is the right way to approach including the documentation for testing, but I don't know what the "right way" is off the top of my head.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What don't you like about sub-moduling?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it fairly easy for someone developing to run tests but quite difficult if you've just installed a release version and want to run the tests.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

submodules are not fetched automatically you need to ask git to do that, so if you haven't pull matrix-doc it just runs tests without providing you with spec-coverage.

On the other-hand if we move towards using the matrix-doc to also create / respond to mock requests (we don't currently) than indeed you would need matrix-doc to run tests.

path = matrix-doc
url = https://github.com/matrix-org/matrix-doc.git
1 change: 1 addition & 0 deletions matrix-doc
Submodule matrix-doc added at d643b6
15 changes: 5 additions & 10 deletions matrix_client/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,20 +97,15 @@ def validate_certificate(self, valid):
self.validate_cert = valid
return

def register(self, login_type, **kwargs):
def register(self, content={}, query_params={}):
"""Performs /register.

Args:
login_type(str): The value for the 'type' key.
**kwargs: Additional key/values to add to the JSON submitted.
content(dict): The request payload. Should include "type" such as "m.login.password" for all non-guest registrations.
query_params(dict): The query params for the request. Specify "kind": "guest" to register a guest account.
"""
content = {
"type": login_type
}
for key in kwargs:
content[key] = kwargs[key]

return self._send("POST", "/register", content, api_path=MATRIX_V2_API_PATH)
return self._send("POST", "/register", content=content, query_params=query_params, api_path=MATRIX_V2_API_PATH)

def login(self, login_type, **kwargs):
"""Perform /login.
Expand DownExpand Up@@ -504,7 +499,7 @@ def create_filter(self, user_id, filter_params):
api_path=MATRIX_V2_API_PATH)

def _send(self, method, path, content=None, query_params={}, headers={},
api_path="/_matrix/client/api/v1"):
api_path=MATRIX_V2_API_PATH):
method = method.upper()
if method not in ["GET", "PUT", "DELETE", "POST"]:
raise MatrixError("Unsupported HTTP method: %s" % method)
Expand Down
19 changes: 18 additions & 1 deletion matrix_client/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,23 @@ def set_sync_token(self, token):
def set_user_id(self, user_id):
self.user_id = user_id

def register_as_guest(self):
""" Register a guest account on this HS.
Note: HS must have guest registration enabled.
Returns:
str: Access Token
Raises:
MatrixRequestError
"""
response = self.api.register(query_params={'kind': 'guest'})
self.user_id = response["user_id"]
self.token = response["access_token"]
self.hs = response["home_server"]
self.api.token = self.token
self.sync_filter = '{ "room": { "timeline" : { "limit" : 20 } } }'
self._sync()
return self.token

def register_with_password(self, username, password, limit=1):
""" Register for a new account on this HS.

Expand All@@ -127,7 +144,7 @@ def register_with_password(self, username, password, limit=1):
MatrixRequestError
"""
response = self.api.register(
"m.login.password", user=username, password=password
{'type': "m.login.password", 'user': username, 'password': password}
)
self.user_id = response["user_id"]
self.token = response["access_token"]
Expand Down
11 changes: 7 additions & 4 deletions test/api_test.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
import responses
import pytest
responses = pytest.responses_with_api_guide
if not responses:
import responses
from matrix_client import client


class TestTagsApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
Expand DownExpand Up@@ -67,14 +69,15 @@ class TestUnbanApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
room_id = "#foo:matrix.org"

@responses.activate
def test_unban(self):
unban_url = "http://example.com" \
"/_matrix/client/api/v1/rooms/#foo:matrix.org/unban"
"/_matrix/client/r0/rooms/#foo:matrix.org/unban"
body = '{"user_id": "'+ self.user_id + '"}'
responses.add(responses.POST, unban_url, body=body)
self.cli.api.unban_user(self.room_id, self.user_id)
req = responses.calls[0].request
assert req.url == unban_url
assert req.method == 'POST'

1 change: 1 addition & 0 deletions test/conftest.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
pytest_plugins = "matrix_spec_coverage_plugin"
94 changes: 94 additions & 0 deletions test/matrix_spec_coverage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
import sys
import re
import yaml
from responses import RequestsMock

INTERPOLATIONS = [
("%CLIENT_MAJOR_VERSION%", "r0")

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed the spec only lists %CLIENT_MAJOR_VERSION% for all it's docs, potentially this is fail-prone where api/v1 and api/v2 actually differ in Synapse, probably needs to have an issue opened on matrix-doc.

]

def interpolate_str(s):
for interpolation in INTERPOLATIONS:
s = s.replace(interpolation[0], interpolation[1])
return s

def endpoint_to_regex(s):
# TODO sub by with more specific REGEXes per type
# e.g. roomId, eventId, userId
return re.sub('\{[a-zA-Z]+\}', '[a-zA-Z!\.:-@#]+', s)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bad, requests, or some other library should provide a way to directly assert whether a request matches e.g. /room_id/{roomId} I haven't really looked into it, but regexing is not the preferred way for sure.


MISSING_BASE_PATH = "Not a valid API Base Path: "
MISSING_ENDPOINT = "Not a valid API Endpoint: "
MISSING_METHOD = "Not a valid API Method: "

class ApiGuide:

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of this class-name - but I'd prefer not to have something very long like MatrixApiCoverageStatGatherer, though maybe that's better than this?

def __init__(self, hostname="http://example.com"):
self.hostname = hostname
self.endpoints = {}
self.called = []
self.missing = []
self.total_endpoints = 0

def setup_from_files(self, files):
for file in files:
with open(file) as rfile:
definitions = yaml.load(rfile)
base_path = definitions['basePath']
resolved_base_path = interpolate_str(base_path)
if resolved_base_path not in self.endpoints:
self.endpoints[resolved_base_path] = {}
regex_paths = { endpoint_to_regex(k): v for k,v in definitions['paths'].items() }
self.endpoints[resolved_base_path].update(regex_paths)
endpoints_added = sum(len(v) for v in definitions['paths'].values())
self.total_endpoints += endpoints_added

def process_request(self, request):
full_path_url = request.url
method = request.method
body = request.body
for base_path in self.endpoints.keys():
if base_path in full_path_url:
path_url = full_path_url.replace(base_path, '')
path_url = path_url.replace(self.hostname, '')
break
else:
self.add_called_missing(MISSING_BASE_PATH, request)
return
endpoints = self.endpoints[base_path]
for endpoint in endpoints.keys():
if re.fullmatch(endpoint, path_url):
break
else:
self.add_called_missing(MISSING_ENDPOINT, request)
return
endpoint_def = endpoints[endpoint]
try:
endpoint_def[method.lower()]
self.add_called(base_path, endpoint, method, body)
except KeyError:
self.add_called_missing(MISSING_METHOD, request)


def add_called(self, base_path, endpoint, method, body):
self.called.append((base_path, endpoint, method, body))

def add_called_missing(self, error,request):
self.missing.append((error, request.url, request.method, request.body))

def print_summary(self):
print("Accessed: %i out of %i endpoints. %0.2f%% Coverage." %
(len(self.called), self.total_endpoints, len(self.called)*100 / self.total_endpoints)
)
if self.missing:
missing_summary = "\n".join(m[0] + ", ".join(m[1:-1]) for m in self.missing)
raise AssertionError("The following invalid API Requests were made:\n" +
missing_summary)

class RequestsMockWithApiGuide(RequestsMock):
def __init__(self, api_guide, assert_all_requests_are_fired=True):
self.api_guide = api_guide
super().__init__(assert_all_requests_are_fired)

def _on_request(self, adapter, request, **kwargs):
self.api_guide.process_request(request)
return super()._on_request(adapter, request, **kwargs)
32 changes: 32 additions & 0 deletions test/matrix_spec_coverage_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import _pytest
import pytest
from _pytest._pluggy import HookspecMarker
from matrix_spec_coverage import ApiGuide, RequestsMockWithApiGuide

hookspec = HookspecMarker("pytest")

# We use this to print api_guide coverage stats
# after pytest has finished running
def pytest_terminal_summary(terminalreporter, exitstatus):
if pytest.responses_with_api_guide:
guide = pytest.responses_with_api_guide.api_guide
guide.print_summary()


def build_api_guide():
import os
from glob import glob
DOC_FOLDER = "../matrix-doc/api/client-server/"
api_files = glob(os.path.join(DOC_FOLDER, '*.yaml'))
if not api_files:
return
guide = ApiGuide()
guide.setup_from_files(API_FILES)
return guide

# Load api_guide stats into the pytest namespace so
# that we can print a the stats on terminal summary
@hookspec(historic=True)
def pytest_namespace():
guide = build_api_guide()
return { 'responses_with_api_guide': RequestsMockWithApiGuide(guide) }
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Spec Coverage via. matrix-doc in pytest by pik · Pull Request #125 · matrix-org/matrix-python-sdk · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
[submodule "matrix-doc"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a git submodule is the right way to approach including the documentation for testing, but I don't know what the "right way" is off the top of my head.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What don't you like about sub-moduling?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it fairly easy for someone developing to run tests but quite difficult if you've just installed a release version and want to run the tests.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

submodules are not fetched automatically you need to ask git to do that, so if you haven't pull matrix-doc it just runs tests without providing you with spec-coverage.

On the other-hand if we move towards using the matrix-doc to also create / respond to mock requests (we don't currently) than indeed you would need matrix-doc to run tests.

path = matrix-doc
url = https://github.com/matrix-org/matrix-doc.git
1 change: 1 addition & 0 deletions matrix-doc
Submodule matrix-doc added at d643b6
15 changes: 5 additions & 10 deletions matrix_client/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,20 +97,15 @@ def validate_certificate(self, valid):
self.validate_cert = valid
return

def register(self, login_type, **kwargs):
def register(self, content={}, query_params={}):
"""Performs /register.

Args:
login_type(str): The value for the 'type' key.
**kwargs: Additional key/values to add to the JSON submitted.
content(dict): The request payload. Should include "type" such as "m.login.password" for all non-guest registrations.
query_params(dict): The query params for the request. Specify "kind": "guest" to register a guest account.
"""
content = {
"type": login_type
}
for key in kwargs:
content[key] = kwargs[key]

return self._send("POST", "/register", content, api_path=MATRIX_V2_API_PATH)
return self._send("POST", "/register", content=content, query_params=query_params, api_path=MATRIX_V2_API_PATH)

def login(self, login_type, **kwargs):
"""Perform /login.
Expand DownExpand Up@@ -504,7 +499,7 @@ def create_filter(self, user_id, filter_params):
api_path=MATRIX_V2_API_PATH)

def _send(self, method, path, content=None, query_params={}, headers={},
api_path="/_matrix/client/api/v1"):
api_path=MATRIX_V2_API_PATH):
method = method.upper()
if method not in ["GET", "PUT", "DELETE", "POST"]:
raise MatrixError("Unsupported HTTP method: %s" % method)
Expand Down
19 changes: 18 additions & 1 deletion matrix_client/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,23 @@ def set_sync_token(self, token):
def set_user_id(self, user_id):
self.user_id = user_id

def register_as_guest(self):
""" Register a guest account on this HS.
Note: HS must have guest registration enabled.
Returns:
str: Access Token
Raises:
MatrixRequestError
"""
response = self.api.register(query_params={'kind': 'guest'})
self.user_id = response["user_id"]
self.token = response["access_token"]
self.hs = response["home_server"]
self.api.token = self.token
self.sync_filter = '{ "room": { "timeline" : { "limit" : 20 } } }'
self._sync()
return self.token

def register_with_password(self, username, password, limit=1):
""" Register for a new account on this HS.

Expand All@@ -127,7 +144,7 @@ def register_with_password(self, username, password, limit=1):
MatrixRequestError
"""
response = self.api.register(
"m.login.password", user=username, password=password
{'type': "m.login.password", 'user': username, 'password': password}
)
self.user_id = response["user_id"]
self.token = response["access_token"]
Expand Down
11 changes: 7 additions & 4 deletions test/api_test.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
import responses
import pytest
responses = pytest.responses_with_api_guide
if not responses:
import responses
from matrix_client import client


class TestTagsApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
Expand DownExpand Up@@ -67,14 +69,15 @@ class TestUnbanApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
room_id = "#foo:matrix.org"

@responses.activate
def test_unban(self):
unban_url = "http://example.com" \
"/_matrix/client/api/v1/rooms/#foo:matrix.org/unban"
"/_matrix/client/r0/rooms/#foo:matrix.org/unban"
body = '{"user_id": "'+ self.user_id + '"}'
responses.add(responses.POST, unban_url, body=body)
self.cli.api.unban_user(self.room_id, self.user_id)
req = responses.calls[0].request
assert req.url == unban_url
assert req.method == 'POST'

1 change: 1 addition & 0 deletions test/conftest.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
pytest_plugins = "matrix_spec_coverage_plugin"
94 changes: 94 additions & 0 deletions test/matrix_spec_coverage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
import sys
import re
import yaml
from responses import RequestsMock

INTERPOLATIONS = [
("%CLIENT_MAJOR_VERSION%", "r0")

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed the spec only lists %CLIENT_MAJOR_VERSION% for all it's docs, potentially this is fail-prone where api/v1 and api/v2 actually differ in Synapse, probably needs to have an issue opened on matrix-doc.

]

def interpolate_str(s):
for interpolation in INTERPOLATIONS:
s = s.replace(interpolation[0], interpolation[1])
return s

def endpoint_to_regex(s):
# TODO sub by with more specific REGEXes per type
# e.g. roomId, eventId, userId
return re.sub('\{[a-zA-Z]+\}', '[a-zA-Z!\.:-@#]+', s)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bad, requests, or some other library should provide a way to directly assert whether a request matches e.g. /room_id/{roomId} I haven't really looked into it, but regexing is not the preferred way for sure.


MISSING_BASE_PATH = "Not a valid API Base Path: "
MISSING_ENDPOINT = "Not a valid API Endpoint: "
MISSING_METHOD = "Not a valid API Method: "

class ApiGuide:

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of this class-name - but I'd prefer not to have something very long like MatrixApiCoverageStatGatherer, though maybe that's better than this?

def __init__(self, hostname="http://example.com"):
self.hostname = hostname
self.endpoints = {}
self.called = []
self.missing = []
self.total_endpoints = 0

def setup_from_files(self, files):
for file in files:
with open(file) as rfile:
definitions = yaml.load(rfile)
base_path = definitions['basePath']
resolved_base_path = interpolate_str(base_path)
if resolved_base_path not in self.endpoints:
self.endpoints[resolved_base_path] = {}
regex_paths = { endpoint_to_regex(k): v for k,v in definitions['paths'].items() }
self.endpoints[resolved_base_path].update(regex_paths)
endpoints_added = sum(len(v) for v in definitions['paths'].values())
self.total_endpoints += endpoints_added

def process_request(self, request):
full_path_url = request.url
method = request.method
body = request.body
for base_path in self.endpoints.keys():
if base_path in full_path_url:
path_url = full_path_url.replace(base_path, '')
path_url = path_url.replace(self.hostname, '')
break
else:
self.add_called_missing(MISSING_BASE_PATH, request)
return
endpoints = self.endpoints[base_path]
for endpoint in endpoints.keys():
if re.fullmatch(endpoint, path_url):
break
else:
self.add_called_missing(MISSING_ENDPOINT, request)
return
endpoint_def = endpoints[endpoint]
try:
endpoint_def[method.lower()]
self.add_called(base_path, endpoint, method, body)
except KeyError:
self.add_called_missing(MISSING_METHOD, request)


def add_called(self, base_path, endpoint, method, body):
self.called.append((base_path, endpoint, method, body))

def add_called_missing(self, error,request):
self.missing.append((error, request.url, request.method, request.body))

def print_summary(self):
print("Accessed: %i out of %i endpoints. %0.2f%% Coverage." %
(len(self.called), self.total_endpoints, len(self.called)*100 / self.total_endpoints)
)
if self.missing:
missing_summary = "\n".join(m[0] + ", ".join(m[1:-1]) for m in self.missing)
raise AssertionError("The following invalid API Requests were made:\n" +
missing_summary)

class RequestsMockWithApiGuide(RequestsMock):
def __init__(self, api_guide, assert_all_requests_are_fired=True):
self.api_guide = api_guide
super().__init__(assert_all_requests_are_fired)

def _on_request(self, adapter, request, **kwargs):
self.api_guide.process_request(request)
return super()._on_request(adapter, request, **kwargs)
32 changes: 32 additions & 0 deletions test/matrix_spec_coverage_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import _pytest
import pytest
from _pytest._pluggy import HookspecMarker
from matrix_spec_coverage import ApiGuide, RequestsMockWithApiGuide

hookspec = HookspecMarker("pytest")

# We use this to print api_guide coverage stats
# after pytest has finished running
def pytest_terminal_summary(terminalreporter, exitstatus):
if pytest.responses_with_api_guide:
guide = pytest.responses_with_api_guide.api_guide
guide.print_summary()


def build_api_guide():
import os
from glob import glob
DOC_FOLDER = "../matrix-doc/api/client-server/"
api_files = glob(os.path.join(DOC_FOLDER, '*.yaml'))
if not api_files:
return
guide = ApiGuide()
guide.setup_from_files(API_FILES)
return guide

# Load api_guide stats into the pytest namespace so
# that we can print a the stats on terminal summary
@hookspec(historic=True)
def pytest_namespace():
guide = build_api_guide()
return { 'responses_with_api_guide': RequestsMockWithApiGuide(guide) }
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Spec Coverage via. matrix-doc in pytest by pik · Pull Request #125 · matrix-org/matrix-python-sdk · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
[submodule "matrix-doc"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a git submodule is the right way to approach including the documentation for testing, but I don't know what the "right way" is off the top of my head.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What don't you like about sub-moduling?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it fairly easy for someone developing to run tests but quite difficult if you've just installed a release version and want to run the tests.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

submodules are not fetched automatically you need to ask git to do that, so if you haven't pull matrix-doc it just runs tests without providing you with spec-coverage.

On the other-hand if we move towards using the matrix-doc to also create / respond to mock requests (we don't currently) than indeed you would need matrix-doc to run tests.

path = matrix-doc
url = https://github.com/matrix-org/matrix-doc.git
1 change: 1 addition & 0 deletions matrix-doc
Submodule matrix-doc added at d643b6
15 changes: 5 additions & 10 deletions matrix_client/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,20 +97,15 @@ def validate_certificate(self, valid):
self.validate_cert = valid
return

def register(self, login_type, **kwargs):
def register(self, content={}, query_params={}):
"""Performs /register.

Args:
login_type(str): The value for the 'type' key.
**kwargs: Additional key/values to add to the JSON submitted.
content(dict): The request payload. Should include "type" such as "m.login.password" for all non-guest registrations.
query_params(dict): The query params for the request. Specify "kind": "guest" to register a guest account.
"""
content = {
"type": login_type
}
for key in kwargs:
content[key] = kwargs[key]

return self._send("POST", "/register", content, api_path=MATRIX_V2_API_PATH)
return self._send("POST", "/register", content=content, query_params=query_params, api_path=MATRIX_V2_API_PATH)

def login(self, login_type, **kwargs):
"""Perform /login.
Expand DownExpand Up@@ -504,7 +499,7 @@ def create_filter(self, user_id, filter_params):
api_path=MATRIX_V2_API_PATH)

def _send(self, method, path, content=None, query_params={}, headers={},
api_path="/_matrix/client/api/v1"):
api_path=MATRIX_V2_API_PATH):
method = method.upper()
if method not in ["GET", "PUT", "DELETE", "POST"]:
raise MatrixError("Unsupported HTTP method: %s" % method)
Expand Down
19 changes: 18 additions & 1 deletion matrix_client/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,23 @@ def set_sync_token(self, token):
def set_user_id(self, user_id):
self.user_id = user_id

def register_as_guest(self):
""" Register a guest account on this HS.
Note: HS must have guest registration enabled.
Returns:
str: Access Token
Raises:
MatrixRequestError
"""
response = self.api.register(query_params={'kind': 'guest'})
self.user_id = response["user_id"]
self.token = response["access_token"]
self.hs = response["home_server"]
self.api.token = self.token
self.sync_filter = '{ "room": { "timeline" : { "limit" : 20 } } }'
self._sync()
return self.token

def register_with_password(self, username, password, limit=1):
""" Register for a new account on this HS.

Expand All@@ -127,7 +144,7 @@ def register_with_password(self, username, password, limit=1):
MatrixRequestError
"""
response = self.api.register(
"m.login.password", user=username, password=password
{'type': "m.login.password", 'user': username, 'password': password}
)
self.user_id = response["user_id"]
self.token = response["access_token"]
Expand Down
11 changes: 7 additions & 4 deletions test/api_test.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
import responses
import pytest
responses = pytest.responses_with_api_guide
if not responses:
import responses
from matrix_client import client


class TestTagsApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
Expand DownExpand Up@@ -67,14 +69,15 @@ class TestUnbanApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
room_id = "#foo:matrix.org"

@responses.activate
def test_unban(self):
unban_url = "http://example.com" \
"/_matrix/client/api/v1/rooms/#foo:matrix.org/unban"
"/_matrix/client/r0/rooms/#foo:matrix.org/unban"
body = '{"user_id": "'+ self.user_id + '"}'
responses.add(responses.POST, unban_url, body=body)
self.cli.api.unban_user(self.room_id, self.user_id)
req = responses.calls[0].request
assert req.url == unban_url
assert req.method == 'POST'

1 change: 1 addition & 0 deletions test/conftest.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
pytest_plugins = "matrix_spec_coverage_plugin"
94 changes: 94 additions & 0 deletions test/matrix_spec_coverage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
import sys
import re
import yaml
from responses import RequestsMock

INTERPOLATIONS = [
("%CLIENT_MAJOR_VERSION%", "r0")

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed the spec only lists %CLIENT_MAJOR_VERSION% for all it's docs, potentially this is fail-prone where api/v1 and api/v2 actually differ in Synapse, probably needs to have an issue opened on matrix-doc.

]

def interpolate_str(s):
for interpolation in INTERPOLATIONS:
s = s.replace(interpolation[0], interpolation[1])
return s

def endpoint_to_regex(s):
# TODO sub by with more specific REGEXes per type
# e.g. roomId, eventId, userId
return re.sub('\{[a-zA-Z]+\}', '[a-zA-Z!\.:-@#]+', s)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bad, requests, or some other library should provide a way to directly assert whether a request matches e.g. /room_id/{roomId} I haven't really looked into it, but regexing is not the preferred way for sure.


MISSING_BASE_PATH = "Not a valid API Base Path: "
MISSING_ENDPOINT = "Not a valid API Endpoint: "
MISSING_METHOD = "Not a valid API Method: "

class ApiGuide:

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of this class-name - but I'd prefer not to have something very long like MatrixApiCoverageStatGatherer, though maybe that's better than this?

def __init__(self, hostname="http://example.com"):
self.hostname = hostname
self.endpoints = {}
self.called = []
self.missing = []
self.total_endpoints = 0

def setup_from_files(self, files):
for file in files:
with open(file) as rfile:
definitions = yaml.load(rfile)
base_path = definitions['basePath']
resolved_base_path = interpolate_str(base_path)
if resolved_base_path not in self.endpoints:
self.endpoints[resolved_base_path] = {}
regex_paths = { endpoint_to_regex(k): v for k,v in definitions['paths'].items() }
self.endpoints[resolved_base_path].update(regex_paths)
endpoints_added = sum(len(v) for v in definitions['paths'].values())
self.total_endpoints += endpoints_added

def process_request(self, request):
full_path_url = request.url
method = request.method
body = request.body
for base_path in self.endpoints.keys():
if base_path in full_path_url:
path_url = full_path_url.replace(base_path, '')
path_url = path_url.replace(self.hostname, '')
break
else:
self.add_called_missing(MISSING_BASE_PATH, request)
return
endpoints = self.endpoints[base_path]
for endpoint in endpoints.keys():
if re.fullmatch(endpoint, path_url):
break
else:
self.add_called_missing(MISSING_ENDPOINT, request)
return
endpoint_def = endpoints[endpoint]
try:
endpoint_def[method.lower()]
self.add_called(base_path, endpoint, method, body)
except KeyError:
self.add_called_missing(MISSING_METHOD, request)


def add_called(self, base_path, endpoint, method, body):
self.called.append((base_path, endpoint, method, body))

def add_called_missing(self, error,request):
self.missing.append((error, request.url, request.method, request.body))

def print_summary(self):
print("Accessed: %i out of %i endpoints. %0.2f%% Coverage." %
(len(self.called), self.total_endpoints, len(self.called)*100 / self.total_endpoints)
)
if self.missing:
missing_summary = "\n".join(m[0] + ", ".join(m[1:-1]) for m in self.missing)
raise AssertionError("The following invalid API Requests were made:\n" +
missing_summary)

class RequestsMockWithApiGuide(RequestsMock):
def __init__(self, api_guide, assert_all_requests_are_fired=True):
self.api_guide = api_guide
super().__init__(assert_all_requests_are_fired)

def _on_request(self, adapter, request, **kwargs):
self.api_guide.process_request(request)
return super()._on_request(adapter, request, **kwargs)
32 changes: 32 additions & 0 deletions test/matrix_spec_coverage_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import _pytest
import pytest
from _pytest._pluggy import HookspecMarker
from matrix_spec_coverage import ApiGuide, RequestsMockWithApiGuide

hookspec = HookspecMarker("pytest")

# We use this to print api_guide coverage stats
# after pytest has finished running
def pytest_terminal_summary(terminalreporter, exitstatus):
if pytest.responses_with_api_guide:
guide = pytest.responses_with_api_guide.api_guide
guide.print_summary()


def build_api_guide():
import os
from glob import glob
DOC_FOLDER = "../matrix-doc/api/client-server/"
api_files = glob(os.path.join(DOC_FOLDER, '*.yaml'))
if not api_files:
return
guide = ApiGuide()
guide.setup_from_files(API_FILES)
return guide

# Load api_guide stats into the pytest namespace so
# that we can print a the stats on terminal summary
@hookspec(historic=True)
def pytest_namespace():
guide = build_api_guide()
return { 'responses_with_api_guide': RequestsMockWithApiGuide(guide) }
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Spec Coverage via. matrix-doc in pytest by pik · Pull Request #125 · matrix-org/matrix-python-sdk · GitHub
Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitmodules
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
[submodule "matrix-doc"]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think a git submodule is the right way to approach including the documentation for testing, but I don't know what the "right way" is off the top of my head.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What don't you like about sub-moduling?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It makes it fairly easy for someone developing to run tests but quite difficult if you've just installed a release version and want to run the tests.

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

submodules are not fetched automatically you need to ask git to do that, so if you haven't pull matrix-doc it just runs tests without providing you with spec-coverage.

On the other-hand if we move towards using the matrix-doc to also create / respond to mock requests (we don't currently) than indeed you would need matrix-doc to run tests.

path = matrix-doc
url = https://github.com/matrix-org/matrix-doc.git
1 change: 1 addition & 0 deletions matrix-doc
Submodule matrix-doc added at d643b6
15 changes: 5 additions & 10 deletions matrix_client/api.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -97,20 +97,15 @@ def validate_certificate(self, valid):
self.validate_cert = valid
return

def register(self, login_type, **kwargs):
def register(self, content={}, query_params={}):
"""Performs /register.

Args:
login_type(str): The value for the 'type' key.
**kwargs: Additional key/values to add to the JSON submitted.
content(dict): The request payload. Should include "type" such as "m.login.password" for all non-guest registrations.
query_params(dict): The query params for the request. Specify "kind": "guest" to register a guest account.
"""
content = {
"type": login_type
}
for key in kwargs:
content[key] = kwargs[key]

return self._send("POST", "/register", content, api_path=MATRIX_V2_API_PATH)
return self._send("POST", "/register", content=content, query_params=query_params, api_path=MATRIX_V2_API_PATH)

def login(self, login_type, **kwargs):
"""Perform /login.
Expand DownExpand Up@@ -504,7 +499,7 @@ def create_filter(self, user_id, filter_params):
api_path=MATRIX_V2_API_PATH)

def _send(self, method, path, content=None, query_params={}, headers={},
api_path="/_matrix/client/api/v1"):
api_path=MATRIX_V2_API_PATH):
method = method.upper()
if method not in ["GET", "PUT", "DELETE", "POST"]:
raise MatrixError("Unsupported HTTP method: %s" % method)
Expand Down
19 changes: 18 additions & 1 deletion matrix_client/client.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -112,6 +112,23 @@ def set_sync_token(self, token):
def set_user_id(self, user_id):
self.user_id = user_id

def register_as_guest(self):
""" Register a guest account on this HS.
Note: HS must have guest registration enabled.
Returns:
str: Access Token
Raises:
MatrixRequestError
"""
response = self.api.register(query_params={'kind': 'guest'})
self.user_id = response["user_id"]
self.token = response["access_token"]
self.hs = response["home_server"]
self.api.token = self.token
self.sync_filter = '{ "room": { "timeline" : { "limit" : 20 } } }'
self._sync()
return self.token

def register_with_password(self, username, password, limit=1):
""" Register for a new account on this HS.

Expand All@@ -127,7 +144,7 @@ def register_with_password(self, username, password, limit=1):
MatrixRequestError
"""
response = self.api.register(
"m.login.password", user=username, password=password
{'type': "m.login.password", 'user': username, 'password': password}
)
self.user_id = response["user_id"]
self.token = response["access_token"]
Expand Down
11 changes: 7 additions & 4 deletions test/api_test.py
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
import responses
import pytest
responses = pytest.responses_with_api_guide
if not responses:
import responses
from matrix_client import client


class TestTagsApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
Expand DownExpand Up@@ -67,14 +69,15 @@ class TestUnbanApi:
cli = client.MatrixClient("http://example.com")
user_id = "@user:matrix.org"
room_id = "#foo:matrix.org"

@responses.activate
def test_unban(self):
unban_url = "http://example.com" \
"/_matrix/client/api/v1/rooms/#foo:matrix.org/unban"
"/_matrix/client/r0/rooms/#foo:matrix.org/unban"
body = '{"user_id": "'+ self.user_id + '"}'
responses.add(responses.POST, unban_url, body=body)
self.cli.api.unban_user(self.room_id, self.user_id)
req = responses.calls[0].request
assert req.url == unban_url
assert req.method == 'POST'

1 change: 1 addition & 0 deletions test/conftest.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
pytest_plugins = "matrix_spec_coverage_plugin"
94 changes: 94 additions & 0 deletions test/matrix_spec_coverage.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
import sys
import re
import yaml
from responses import RequestsMock

INTERPOLATIONS = [
("%CLIENT_MAJOR_VERSION%", "r0")

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed the spec only lists %CLIENT_MAJOR_VERSION% for all it's docs, potentially this is fail-prone where api/v1 and api/v2 actually differ in Synapse, probably needs to have an issue opened on matrix-doc.

]

def interpolate_str(s):
for interpolation in INTERPOLATIONS:
s = s.replace(interpolation[0], interpolation[1])
return s

def endpoint_to_regex(s):
# TODO sub by with more specific REGEXes per type
# e.g. roomId, eventId, userId
return re.sub('\{[a-zA-Z]+\}', '[a-zA-Z!\.:-@#]+', s)

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bad, requests, or some other library should provide a way to directly assert whether a request matches e.g. /room_id/{roomId} I haven't really looked into it, but regexing is not the preferred way for sure.


MISSING_BASE_PATH = "Not a valid API Base Path: "
MISSING_ENDPOINT = "Not a valid API Endpoint: "
MISSING_METHOD = "Not a valid API Method: "

class ApiGuide:

Copy link
Copy Markdown
ContributorAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of this class-name - but I'd prefer not to have something very long like MatrixApiCoverageStatGatherer, though maybe that's better than this?

def __init__(self, hostname="http://example.com"):
self.hostname = hostname
self.endpoints = {}
self.called = []
self.missing = []
self.total_endpoints = 0

def setup_from_files(self, files):
for file in files:
with open(file) as rfile:
definitions = yaml.load(rfile)
base_path = definitions['basePath']
resolved_base_path = interpolate_str(base_path)
if resolved_base_path not in self.endpoints:
self.endpoints[resolved_base_path] = {}
regex_paths = { endpoint_to_regex(k): v for k,v in definitions['paths'].items() }
self.endpoints[resolved_base_path].update(regex_paths)
endpoints_added = sum(len(v) for v in definitions['paths'].values())
self.total_endpoints += endpoints_added

def process_request(self, request):
full_path_url = request.url
method = request.method
body = request.body
for base_path in self.endpoints.keys():
if base_path in full_path_url:
path_url = full_path_url.replace(base_path, '')
path_url = path_url.replace(self.hostname, '')
break
else:
self.add_called_missing(MISSING_BASE_PATH, request)
return
endpoints = self.endpoints[base_path]
for endpoint in endpoints.keys():
if re.fullmatch(endpoint, path_url):
break
else:
self.add_called_missing(MISSING_ENDPOINT, request)
return
endpoint_def = endpoints[endpoint]
try:
endpoint_def[method.lower()]
self.add_called(base_path, endpoint, method, body)
except KeyError:
self.add_called_missing(MISSING_METHOD, request)


def add_called(self, base_path, endpoint, method, body):
self.called.append((base_path, endpoint, method, body))

def add_called_missing(self, error,request):
self.missing.append((error, request.url, request.method, request.body))

def print_summary(self):
print("Accessed: %i out of %i endpoints. %0.2f%% Coverage." %
(len(self.called), self.total_endpoints, len(self.called)*100 / self.total_endpoints)
)
if self.missing:
missing_summary = "\n".join(m[0] + ", ".join(m[1:-1]) for m in self.missing)
raise AssertionError("The following invalid API Requests were made:\n" +
missing_summary)

class RequestsMockWithApiGuide(RequestsMock):
def __init__(self, api_guide, assert_all_requests_are_fired=True):
self.api_guide = api_guide
super().__init__(assert_all_requests_are_fired)

def _on_request(self, adapter, request, **kwargs):
self.api_guide.process_request(request)
return super()._on_request(adapter, request, **kwargs)
32 changes: 32 additions & 0 deletions test/matrix_spec_coverage_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
import _pytest
import pytest
from _pytest._pluggy import HookspecMarker
from matrix_spec_coverage import ApiGuide, RequestsMockWithApiGuide

hookspec = HookspecMarker("pytest")

# We use this to print api_guide coverage stats
# after pytest has finished running
def pytest_terminal_summary(terminalreporter, exitstatus):
if pytest.responses_with_api_guide:
guide = pytest.responses_with_api_guide.api_guide
guide.print_summary()


def build_api_guide():
import os
from glob import glob
DOC_FOLDER = "../matrix-doc/api/client-server/"
api_files = glob(os.path.join(DOC_FOLDER, '*.yaml'))
if not api_files:
return
guide = ApiGuide()
guide.setup_from_files(API_FILES)
return guide

# Load api_guide stats into the pytest namespace so
# that we can print a the stats on terminal summary
@hookspec(historic=True)
def pytest_namespace():
guide = build_api_guide()
return { 'responses_with_api_guide': RequestsMockWithApiGuide(guide) }