Skip to content

Commit efed2b0

Browse files
[feat] implement check update hook (#8)
* Create check_update.py * Add tests * add payload extraction * Got the basic code working * getting better * Add unit test * Add unit tests * Improve type hinting * Improve script and tests * Make it work with python 3.6 * Improved hook * Update managed_os_env_vars.py * fix linting issues * Update slack_cli_hooks/hooks/check_update.py Co-authored-by: Kazuhiro Sera <seratch@gmail.com> * Update slack_cli_hooks/error/__init__.py Co-authored-by: Kazuhiro Sera <seratch@gmail.com> * Fix test --------- Co-authored-by: Kazuhiro Sera <seratch@gmail.com>
1 parent 8e64ddb commit efed2b0

8 files changed

Lines changed: 312 additions & 4 deletions

File tree

slack_cli_hooks/error/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,6 @@
11
class CliError(Exception):
22
"""General class for cli error"""
3+
4+
5+
class PypiError(Exception):
6+
"""General class for PyPI package info retrieval error"""
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env python
2+
import json
3+
from http.client import HTTPResponse
4+
from types import ModuleType
5+
from typing import Any, Dict, List, Optional
6+
from urllib import request
7+
8+
import slack_bolt
9+
import slack_sdk
10+
from pkg_resources import parse_version as Version
11+
12+
import slack_cli_hooks.version
13+
from slack_cli_hooks.error import PypiError
14+
from slack_cli_hooks.protocol import Protocol, build_protocol
15+
16+
PROTOCOL: Protocol
17+
18+
DEPENDENCIES: List[ModuleType] = [slack_cli_hooks, slack_bolt, slack_sdk]
19+
20+
21+
def parse_major(v: Version) -> int:
22+
"""The first item of :attr:`release` or ``0`` if unavailable.
23+
24+
>>> parse_major(Version("1.2.3"))
25+
1
26+
"""
27+
# This implementation comes directly from the Version implementation since it is not supported in 3.6
28+
# source: https://github.com/pypa/packaging/blob/main/src/packaging/version.py
29+
return v._version.release[0] if len(v._version) >= 1 else 0 # type: ignore
30+
31+
32+
class Release:
33+
def __init__(
34+
self,
35+
name: str,
36+
current: Optional[Version] = None,
37+
latest: Optional[Version] = None,
38+
message: Optional[str] = None,
39+
url: Optional[str] = None,
40+
error: Optional[Dict[str, str]] = None,
41+
):
42+
self.name = name
43+
if current and latest:
44+
self.current = current.base_version
45+
self.latest = latest.base_version
46+
self.update = current < latest
47+
self.breaking = (parse_major(current) - parse_major(latest)) != 0
48+
if error:
49+
self.error = error
50+
if message:
51+
self.message = message
52+
if url:
53+
self.url = url
54+
55+
56+
def pypi_get(project: str, headers={"Accept": "application/json"}) -> HTTPResponse:
57+
# Based on https://warehouse.pypa.io/api-reference/json.html
58+
url = f"https://pypi.org/pypi/{project}/json"
59+
pypi_request = request.Request(method="GET", url=url, headers=headers)
60+
return request.urlopen(pypi_request)
61+
62+
63+
def pypi_get_json(project: str) -> Dict[str, Any]:
64+
pypi_response = pypi_get(project)
65+
charset = pypi_response.headers.get_content_charset() or "utf-8"
66+
raw_body = pypi_response.read().decode(charset)
67+
if pypi_response.status > 200:
68+
PROTOCOL.debug(f"Received status {pypi_response.status} from {pypi_response.url}")
69+
PROTOCOL.debug(f"Headers {dict(pypi_response.getheaders())}")
70+
PROTOCOL.debug(f"Body {raw_body}")
71+
raise PypiError(f"Received status {pypi_response.status} from {pypi_response.url}")
72+
return json.loads(raw_body)
73+
74+
75+
def extract_latest_version(payload: Dict[str, Any]) -> str:
76+
if "info" not in payload:
77+
raise PypiError("Missing `info` field in pypi payload")
78+
if "version" not in payload["info"]:
79+
raise PypiError("Missing `version` field in pypi payload['info']")
80+
return payload["info"]["version"]
81+
82+
83+
def build_release(dependency: ModuleType) -> Release:
84+
name = dependency.__name__
85+
try:
86+
pypi_json_payload = pypi_get_json(name)
87+
return Release(
88+
name=name,
89+
current=Version(dependency.version.__version__),
90+
latest=Version(extract_latest_version(pypi_json_payload)),
91+
)
92+
except PypiError as e:
93+
return Release(name=name, error={"message": str(e)})
94+
95+
96+
def build_output(dependencies: List[ModuleType] = DEPENDENCIES) -> Dict[str, Any]:
97+
output = {"name": "Slack Bolt", "url": "https://api.slack.com/automation/changelog", "releases": []}
98+
errors = []
99+
100+
for dep in dependencies:
101+
release = build_release(dep)
102+
output["releases"].append(vars(release))
103+
104+
if hasattr(release, "error"):
105+
errors.append(release.name)
106+
107+
if errors:
108+
output["error"] = {"message": f"An error occurred fetching updates for the following packages: {', '.join(errors)}"}
109+
return output
110+
111+
112+
if __name__ == "__main__":
113+
PROTOCOL = build_protocol()
114+
PROTOCOL.respond(json.dumps(build_output()))

slack_cli_hooks/hooks/get_hooks.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
#!/usr/bin/env python
22
import json
3-
from slack_cli_hooks.protocol import Protocol, MessageBoundaryProtocol, DefaultProtocol, build_protocol
3+
from slack_cli_hooks.protocol import (
4+
Protocol,
5+
MessageBoundaryProtocol,
6+
DefaultProtocol,
7+
build_protocol,
8+
)
49

510
PROTOCOL: Protocol
611
EXEC = "python3"
@@ -10,6 +15,7 @@
1015
"hooks": {
1116
"get-manifest": f"{EXEC} -m slack_cli_hooks.hooks.get_manifest",
1217
"start": f"{EXEC} -X dev -m slack_cli_hooks.hooks.start",
18+
"check-update": f"{EXEC} -m slack_cli_hooks.hooks.check_update",
1319
},
1420
"config": {
1521
"watch": {"filter-regex": "(^manifest\\.json$)", "paths": ["."]},

slack_cli_hooks/hooks/utils/managed_os_env_vars.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,7 @@ def __init__(self, protocol: Protocol) -> None:
1010

1111
def set_if_absent(self, os_env_var: str, value: str) -> None:
1212
if os_env_var in os.environ:
13-
self._protocol.info(
14-
f"{os_env_var} environment variable detected in session, using it over the provided one!"
15-
)
13+
self._protocol.info(f"{os_env_var} environment variable detected in session, using it over the provided one!")
1614
return
1715
self._os_env_vars.append(os_env_var)
1816
os.environ[os_env_var] = value
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
from unittest.mock import patch
2+
from urllib import request
3+
4+
from slack_cli_hooks.hooks import check_update
5+
from slack_cli_hooks.hooks.check_update import build_output
6+
from slack_cli_hooks.protocol.default_protocol import DefaultProtocol
7+
from tests.utils import build_fake_dependency, build_fake_pypi_urlopen
8+
9+
10+
class TestGetManifest:
11+
def setup_method(self):
12+
check_update.PROTOCOL = DefaultProtocol()
13+
14+
def test_build_output(self):
15+
test_project = "test_proj"
16+
fake_pypi_urlopen = build_fake_pypi_urlopen(status=200, body={"info": {"version": "0.0.1"}})
17+
test_dependency = build_fake_dependency(test_project, "0.0.0")
18+
19+
with patch.object(request, "urlopen") as mock_urlopen:
20+
mock_urlopen.side_effect = fake_pypi_urlopen
21+
actual = build_output([test_dependency])
22+
23+
assert actual["name"] == "Slack Bolt"
24+
assert len(actual["releases"]) == 1
25+
assert actual["releases"][0]["name"] == test_project
26+
assert actual["releases"][0]["current"] == "0.0.0"
27+
assert actual["releases"][0]["latest"] == "0.0.1"
28+
assert actual["releases"][0]["update"] is True
29+
assert actual["releases"][0]["breaking"] is False
30+
assert "error" not in actual["releases"][0]
31+
32+
def test_build_output_error(self):
33+
test_project = "test_proj"
34+
fake_pypi_urlopen = build_fake_pypi_urlopen(status=200, body={"info": {}})
35+
test_dependency = build_fake_dependency(test_project, "0.0.0")
36+
37+
with patch.object(request, "urlopen") as mock_urlopen:
38+
mock_urlopen.side_effect = fake_pypi_urlopen
39+
actual = build_output([test_dependency])
40+
41+
assert actual["name"] == "Slack Bolt"
42+
assert len(actual["releases"]) == 1
43+
assert actual["releases"][0]["name"] == test_project
44+
assert "error" in actual["releases"][0]
45+
assert "message" in actual["releases"][0]["error"]
46+
assert "error" in actual
47+
assert "message" in actual["error"]
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
from unittest.mock import patch
2+
from urllib import request
3+
4+
import pytest
5+
6+
from slack_cli_hooks.error import PypiError
7+
from slack_cli_hooks.hooks import check_update
8+
from slack_cli_hooks.hooks.check_update import (
9+
build_output,
10+
build_release,
11+
extract_latest_version,
12+
pypi_get,
13+
pypi_get_json,
14+
)
15+
from slack_cli_hooks.protocol.default_protocol import DefaultProtocol
16+
from tests.utils import build_fake_dependency, build_fake_pypi_urlopen
17+
18+
19+
class TestGetManifest:
20+
def setup_method(self):
21+
check_update.PROTOCOL = DefaultProtocol()
22+
23+
def test_pypi_get(self):
24+
test_project = "test_proj"
25+
fake_pypi_urlopen = build_fake_pypi_urlopen()
26+
27+
with patch.object(request, "urlopen") as mock_urlopen:
28+
mock_urlopen.side_effect = fake_pypi_urlopen
29+
response = pypi_get(test_project)
30+
31+
assert response.url == f"https://pypi.org/pypi/{test_project}/json"
32+
assert response.status == 200
33+
assert response.read().decode("utf-8") == "{}"
34+
35+
def test_pypi_get_json(self):
36+
project = "my_test_project"
37+
fake_pypi_urlopen = build_fake_pypi_urlopen(body={"info": {}, "releases": {}})
38+
39+
with patch.object(request, "urlopen") as mock_urlopen:
40+
mock_urlopen.side_effect = fake_pypi_urlopen
41+
json_response = pypi_get_json(project)
42+
43+
assert json_response == {"info": {}, "releases": {}}
44+
45+
def test_pypi_get_json_fail(self):
46+
project = "my_test_project"
47+
fake_pypi_urlopen = build_fake_pypi_urlopen(status=300)
48+
49+
with patch.object(request, "urlopen") as mock_urlopen:
50+
mock_urlopen.side_effect = fake_pypi_urlopen
51+
with pytest.raises(PypiError) as e:
52+
pypi_get_json(project)
53+
54+
assert "300" in str(e)
55+
assert f"https://pypi.org/pypi/{project}/json" in str(e)
56+
57+
def test_extract_latest_version(self):
58+
test_payload = {"info": {"version": "0.0.0"}}
59+
actual = extract_latest_version(test_payload)
60+
assert actual == "0.0.0"
61+
62+
def test_extract_latest_version_missing_info(self):
63+
test_payload = {}
64+
with pytest.raises(PypiError) as e:
65+
extract_latest_version(test_payload)
66+
assert "info" in str(e)
67+
68+
def test_extract_latest_version_missing_version(self):
69+
test_payload = {"info": {}}
70+
with pytest.raises(PypiError) as e:
71+
extract_latest_version(test_payload)
72+
assert "version" in str(e)
73+
assert "payload['info']" in str(e)
74+
75+
def test_build_release(self):
76+
test_project = "test-dependency"
77+
test_dependency = build_fake_dependency(test_project, "0.0.0")
78+
79+
with patch.object(check_update, pypi_get_json.__name__) as mock_pypi_get_json:
80+
mock_pypi_get_json.return_value = {"info": {"version": "0.0.1"}}
81+
actual = build_release(test_dependency)
82+
83+
assert vars(actual) == {
84+
"name": test_project,
85+
"current": "0.0.0",
86+
"latest": "0.0.1",
87+
"update": True,
88+
"breaking": False,
89+
}
90+
91+
def test_build_release_error(self):
92+
test_project = "test-dependency"
93+
test_dependency = build_fake_dependency(test_project, "0.0.0")
94+
95+
with patch.object(check_update, pypi_get_json.__name__) as mock_pypi_get_json:
96+
mock_pypi_get_json.return_value = {}
97+
actual = build_release(test_dependency)
98+
99+
assert vars(actual) == {
100+
"name": test_project,
101+
"error": {"message": "Missing `info` field in pypi payload"},
102+
}
103+
104+
def test_build_output(self):
105+
actual = build_output([])
106+
107+
assert actual == {
108+
"name": "Slack Bolt",
109+
"url": "https://api.slack.com/automation/changelog",
110+
"releases": [],
111+
}

tests/slack_cli_hooks/hooks/test_get_hooks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ def test_hooks_payload(self):
88

99
assert "slack_cli_hooks.hooks.get_manifest" in hooks["get-manifest"]
1010
assert "slack_cli_hooks.hooks.start" in hooks["start"]
11+
assert "slack_cli_hooks.hooks.check_update" in hooks["check-update"]
1112

1213
def test_hooks_payload_config(self):
1314
config = hooks_payload["config"]

tests/utils.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1+
import json
12
import os
3+
from http.client import HTTPMessage, HTTPResponse
4+
from typing import Callable, Union
5+
from unittest.mock import MagicMock
6+
from urllib.request import Request
27

38

49
def remove_os_env_temporarily() -> dict:
@@ -9,3 +14,25 @@ def remove_os_env_temporarily() -> dict:
914

1015
def restore_os_env(old_env: dict) -> None:
1116
os.environ.update(old_env)
17+
18+
19+
def build_fake_pypi_urlopen(status: int = 200, headers=HTTPMessage(), body={}) -> Callable[..., HTTPResponse]:
20+
headers.add_header("Content-Type", 'application/json; charset="UTF-8"')
21+
22+
mock_resp = HTTPResponse(MagicMock())
23+
mock_resp.headers = headers
24+
mock_resp.status = status
25+
mock_resp.read = MagicMock(return_value=json.dumps(body).encode("UTF-8"))
26+
27+
def fake_urlopen(url: Union[str, Request]):
28+
mock_resp.url = url.full_url if isinstance(url, Request) else url
29+
return mock_resp
30+
31+
return fake_urlopen
32+
33+
34+
def build_fake_dependency(name: str, version: str):
35+
fake_dependency = MagicMock()
36+
fake_dependency.version.__version__ = version
37+
fake_dependency.__name__ = name
38+
return fake_dependency

0 commit comments

Comments
 (0)