Skip to content

Commit 86d0112

Browse files
authored
Merge pull request #24 from bellingcat/4.0/beta
4.0/beta
2 parents ab3cc39 + 39f82ad commit 86d0112

7 files changed

Lines changed: 327 additions & 164 deletions

File tree

‎src/octosuite/app.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import sys
22

3+
from .core.cache import cache
34
from .lib import console, __pkg__, __version__
45
from .tui.menus import Menus
56

@@ -11,3 +12,5 @@ def start():
1112
menu.main()
1213
except KeyboardInterrupt:
1314
sys.exit()
15+
finally:
16+
cache.clear()

‎src/octosuite/core/cache.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import hashlib
2+
import json
3+
4+
__all__ = ["cache"]
5+
6+
7+
class ResponseCache:
8+
"""Simple in-memory cache for API responses."""
9+
10+
def __init__(self):
11+
self._cache = {}
12+
13+
@staticmethod
14+
def _generate_key(url: str, params: dict = None) -> str:
15+
"""Generate a unique cache key from URL and params."""
16+
cache_data = f"{url}:{json.dumps(params or {}, sort_keys=True)}"
17+
return hashlib.md5(cache_data.encode()).hexdigest()
18+
19+
def get(self, url: str, params: dict = None):
20+
"""Get cached response."""
21+
key = self._generate_key(url, params)
22+
return self._cache.get(key)
23+
24+
def set(self, url: str, data, params: dict = None):
25+
"""Cache a response."""
26+
key = self._generate_key(url, params)
27+
self._cache[key] = data
28+
29+
def clear(self):
30+
"""Clear all cached responses."""
31+
self._cache.clear()
32+
33+
def remove(self, url: str, params: dict = None):
34+
"""Remove a specific cached response."""
35+
key = self._generate_key(url, params)
36+
self._cache.pop(key, None)
37+
38+
39+
cache = ResponseCache()
40+
41+
42+
def clear_cache():
43+
"""Clear all cached API responses."""
44+
cache.clear()

‎src/octosuite/core/github.py‎

Lines changed: 44 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import requests
66
from requests import Response
77

8+
from .cache import cache
89
from ..lib import __version__
910

1011
BASE_URL = "https://api.github.com"
@@ -22,10 +23,20 @@ def __init__(
2223
),
2324
):
2425
self.user_agent = user_agent
26+
self.cache = cache
2527

2628
def get(
27-
self, url: str, params: t.Optional[dict] = None, return_response: bool = False
29+
self,
30+
url: str,
31+
params: t.Optional[dict] = None,
32+
return_response: bool = False,
33+
use_cache: bool = True,
2834
) -> t.Union[dict, list, Response]:
35+
if use_cache and not return_response:
36+
cached = self.cache.get(url, params)
37+
if cached is not None:
38+
return cached
39+
2940
response = requests.get(
3041
url=url, params=params, headers={"User-Agent": self.user_agent}
3142
)
@@ -34,33 +45,51 @@ def get(
3445
return response
3546

3647
if response.status_code == 200:
37-
return self._sanitise_response(response=response.json())
48+
sanitised = self.sanitise_response(response=response.json())
49+
50+
# Cache the successful response
51+
if use_cache:
52+
self.cache.set(url, sanitised, params)
53+
54+
return sanitised
55+
3856
return []
3957

4058
def is_valid_entity(
41-
self, entity_type: t.Literal["user", "org", "repo"], **kwargs
59+
self, _type: t.Literal["user", "org", "repo"], **kwargs
4260
) -> bool:
4361
"""Validate if a GitHub entity exists."""
4462
try:
45-
if entity_type == "user":
46-
url = f"https://api.github.com/users/{kwargs.get('username')}"
47-
elif entity_type == "org":
48-
url = f"https://api.github.com/orgs/{kwargs.get('username')}"
49-
elif entity_type == "repo":
50-
url = f"https://api.github.com/repos/{kwargs.get('repo_owner')}/{kwargs.get('repo_name')}"
51-
else:
63+
type_map = {
64+
"user": f"https://api.github.com/users/{kwargs.get('username')}",
65+
"org": f"https://api.github.com/orgs/{kwargs.get('username')}",
66+
"repo": f"https://api.github.com/repos/{kwargs.get('repo_owner')}/{kwargs.get('repo_name')}",
67+
}
68+
69+
url = type_map[_type]
70+
71+
# Check cache first
72+
cached = self.cache.get(url)
73+
if cached is not None:
74+
return True # If cached, entity exists
75+
76+
response = requests.get(url=url, headers={"User-Agent": self.user_agent})
77+
78+
# Only cache if entity exists (status 200)
79+
if response.status_code == 200:
80+
sanitised = self.sanitise_response(response.json())
81+
self.cache.set(url, sanitised)
5282
return True
5383

54-
response = self.get(url=url, return_response=True)
55-
return response.status_code == 200
84+
return False
5685
except requests.RequestException:
5786
return False
5887

59-
def _sanitise_response(self, response: t.Union[dict, list]) -> t.Union[dict, list]:
88+
def sanitise_response(self, response: t.Union[dict, list]) -> t.Union[dict, list]:
6089
pattern = re.compile(r"https://api\.github\.com")
6190

6291
if isinstance(response, list):
63-
return [self._sanitise_response(response=item) for item in response]
92+
return [self.sanitise_response(response=item) for item in response]
6493

6594
if isinstance(response, dict):
6695
keys_to_remove = [
@@ -74,6 +103,6 @@ def _sanitise_response(self, response: t.Union[dict, list]) -> t.Union[dict, lis
74103
# Recursively clean nested dicts/lists
75104
for key, value in response.items():
76105
if isinstance(value, (dict, list)):
77-
response[key] = self._sanitise_response(response=value)
106+
response[key] = self.sanitise_response(response=value)
78107

79108
return response

‎src/octosuite/core/models.py‎

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,27 @@ def __init__(self, source: str):
1414
self.endpoint = None
1515
self.source = source
1616

17-
def exists(self) -> bool:
17+
def exists(self) -> tuple[bool, dict]:
1818
"""Check if the entity exists on GitHub."""
19+
# Check cache first
20+
cached = github.cache.get(self.endpoint)
21+
if cached is not None:
22+
return True, cached
23+
1924
try:
2025
response = github.get(url=self.endpoint, return_response=True)
21-
return response.status_code == 200
22-
except exceptions.RequestException as err:
23-
return False
26+
27+
if response.status_code == 200:
28+
data = response.json()
29+
# Sanitise the data
30+
sanitised = github.sanitise_response(data)
31+
# Cache the sanitised response
32+
github.cache.set(self.endpoint, sanitised)
33+
return True, sanitised
34+
35+
return False, response.json()
36+
except exceptions.RequestException:
37+
return False, {}
2438

2539

2640
class User(GitHubEntity):

‎src/octosuite/lib.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def check_updates():
190190
with console.status("[dim]Checking for updates...[/dim]") as status:
191191
checker = UpdateChecker()
192192
result = checker.check(__pkg__, __version__)
193-
if result:
193+
if result is not None:
194194
status.stop()
195195
message_dialog(title="Update Available", text=result).run()
196196
else:

‎src/octosuite/tui/dialogs.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,28 @@ class Dialogs:
2929
def __init__(self): ...
3030

3131
@staticmethod
32-
def quit() -> bool:
32+
def _boolean(title: str, text: str) -> bool:
3333
try:
3434
result = button_dialog(
35-
title="Quit",
36-
text="This will close the session, continue?",
35+
title=title,
36+
text=text,
3737
buttons=[("Yes", True), ("No", False)],
3838
).run()
3939
return result if result is not None else False
4040
except KeyboardInterrupt:
4141
return True
4242

43+
def quit(self) -> bool:
44+
return self._boolean(
45+
title="Quit", text="This will close the session, continue?"
46+
)
47+
48+
def clear_cache(self) -> bool:
49+
return self._boolean(
50+
title="Clear Cache",
51+
text="This will clear all octosuite caches, continue?",
52+
)
53+
4354
@staticmethod
4455
def license():
4556
message_dialog(title="MIT License", text=LICENSE_NOTICE).run()

0 commit comments

Comments
 (0)