Skip to content

Commit ac858f5

Browse files
committed
Implement tag fetching using git ls-remote --tags
This allows us to skip over using the Github API which can hit the rate limit. We also introduce a cache to avoid the Git server's rate limit
1 parent 464066b commit ac858f5

9 files changed

Lines changed: 205 additions & 119 deletions

File tree

app/cli.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010
from app.utils.gitmastery import (
1111
find_exercise_root,
1212
find_gitmastery_root,
13-
read_gitmastery_config,
1413
read_exercise_config,
14+
read_gitmastery_config,
1515
)
1616
from app.utils.version import Version
1717
from app.version import __version__
@@ -45,6 +45,10 @@ def cli(ctx, verbose) -> None:
4545
ctx.obj[CliContextKey.GITMASTERY_EXERCISE_CONFIG] = exercise_root_config
4646

4747
ctx.obj[CliContextKey.VERBOSE] = verbose
48+
# We make the assumption that within a single command run, the "state of the world"
49+
# is immutable, allowing us to cache things
50+
ctx.obj[CliContextKey.WEB_CACHE] = {}
51+
ctx.obj[CliContextKey.TAG_CACHE] = []
4852

4953
current_version = Version.parse_version_string(__version__)
5054
ctx.obj[CliContextKey.VERSION] = current_version

app/commands/setup_folder.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from app.commands.check.git import git
77
from app.commands.progress.constants import PROGRESS_LOCAL_FOLDER_NAME
88
from app.utils.click import error, info, invoke_command, prompt
9-
from app.utils.version import get_latest_release_exercise_version
9+
from app.utils.exercises import get_latest_release_exercise_version
1010

1111

1212
@click.command("setup")

app/utils/click.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import logging
22
import sys
33
from enum import StrEnum
4-
from typing import Any, NoReturn, Optional
4+
from typing import Any, Dict, List, NoReturn, Optional
55

66
import click
77

@@ -16,6 +16,8 @@ class CliContextKey(StrEnum):
1616
GITMASTERY_EXERCISE_CONFIG = "GITMASTERY_EXERCISE_CONFIG"
1717
VERBOSE = "VERBOSE"
1818
VERSION = "VERSION"
19+
WEB_CACHE = "WEB_CACHE"
20+
TAG_CACHE = "TAG_CACHE"
1921

2022

2123
class ClickColor(StrEnum):
@@ -109,6 +111,14 @@ def get_exercise_root_config() -> Optional[ExerciseConfig]:
109111
)
110112

111113

114+
def get_web_cache() -> Dict[str, str | bytes | Any]:
115+
return click.get_current_context().obj.get(CliContextKey.WEB_CACHE, {})
116+
117+
118+
def get_tag_cache() -> List[str]:
119+
return click.get_current_context().obj.get(CliContextKey.TAG_CACHE, [])
120+
121+
112122
def invoke_command(command: click.Command) -> None:
113123
ctx = click.get_current_context()
114124
ctx.invoke(command)

app/utils/exercises.py

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from typing import List, Optional
2+
3+
from app.utils.click import get_tag_cache, get_verbose
4+
from app.utils.command import run
5+
from app.utils.version import Version
6+
7+
8+
def get_exercises_tags() -> List[str]:
9+
tag_cache = get_tag_cache()
10+
if len(tag_cache) != 0:
11+
if get_verbose():
12+
print("Fetching tags from cache")
13+
# Use the in-memory, per command cache for tags to avoid re-querying
14+
return tag_cache
15+
16+
result = run(
17+
[
18+
"git",
19+
"ls-remote",
20+
"--tags",
21+
"--refs",
22+
"https://github.com/git-mastery/exercises",
23+
]
24+
)
25+
versions = []
26+
if result.is_success() and result.stdout:
27+
lines = result.stdout.split("\n")
28+
for line in lines:
29+
tag_raw = line.split()[1]
30+
if tag_raw.startswith("refs/tags/"):
31+
tag = tag_raw[len("refs/tags/") :]
32+
versions.append(tag)
33+
tag_cache = versions
34+
if get_verbose():
35+
print("Queried for tags to store in cache")
36+
return versions
37+
38+
39+
def get_all_exercise_tags() -> List[Version]:
40+
tags = get_exercises_tags()
41+
return list(sorted([Version.parse_version_string(t) for t in tags], reverse=True))
42+
43+
44+
def get_latest_release_exercise_version() -> Optional[Version]:
45+
all_tags = get_all_exercise_tags()
46+
if len(all_tags) == 0:
47+
# Although this should not be happening, we will let the callsite handle this
48+
return None
49+
50+
# These should always ignore the development versions, just focus on the release
51+
# versions
52+
for tag in all_tags:
53+
if tag.prerelease is None:
54+
return tag
55+
56+
return None
57+
58+
59+
def get_latest_development_exercise_version() -> Optional[Version]:
60+
all_tags = get_all_exercise_tags()
61+
if len(all_tags) == 0:
62+
# Although this should not be happening, we will let the callsite handle this
63+
return None
64+
65+
for tag in all_tags:
66+
if tag.build is None:
67+
return tag
68+
return None
69+
70+
71+
def get_latest_exercise_version_within_pin(pin_version: Version) -> Optional[Version]:
72+
all_tags = get_all_exercise_tags()
73+
for tag in all_tags:
74+
if tag.within_pin(pin_version):
75+
return tag
76+
return None

app/utils/git.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from typing import Optional
1+
from typing import List, Optional
22

33
from app.utils.command import run
44

app/utils/gitmastery.py

Lines changed: 53 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,20 @@
1010

1111
from app.exercise_config import ExerciseConfig
1212
from app.gitmastery_config import GitMasteryConfig
13-
from app.utils.click import error, get_exercise_root_config, get_gitmastery_root_config
14-
from app.utils.general import ensure_str
15-
from app.utils.version import (
16-
Version,
13+
from app.utils.click import (
14+
error,
15+
get_exercise_root_config,
16+
get_gitmastery_root_config,
17+
get_verbose,
18+
get_web_cache,
19+
)
20+
from app.utils.exercises import (
21+
get_latest_development_exercise_version,
1722
get_latest_exercise_version_within_pin,
23+
get_latest_release_exercise_version,
1824
)
25+
from app.utils.general import ensure_str
26+
from app.utils.version import Version
1927

2028
GITMASTERY_CONFIG_NAME = ".gitmastery.json"
2129
GITMASTERY_EXERCISE_CONFIG_NAME = ".gitmastery-exercise.json"
@@ -26,22 +34,26 @@
2634

2735
def _construct_gitmastery_exercises_url(filepath: str, version: Version) -> str:
2836
if version.release:
29-
ref = "heads/main"
37+
latest_release = get_latest_release_exercise_version()
38+
if latest_release is None:
39+
raise ValueError("This should not happen. Contact the Git-Mastery team.")
40+
ref = f"tags/v{latest_release.to_version_string()}"
3041
elif version.development:
31-
latest_development = get_latest_exercise_version_within_pin(version)
32-
ref = f"tags/{latest_development}"
42+
latest_development = get_latest_development_exercise_version()
43+
if latest_development is None:
44+
raise ValueError("This should not happen. Contact the Git-Mastery team.")
45+
ref = f"tags/v{latest_development.to_version_string()}"
3346
elif not version.pinned:
34-
ref = f"tags/{version.to_version_string()}"
47+
ref = f"tags/v{version.to_version_string()}"
3548
else:
3649
# If pinned, we need to basically search for all the available tags within the
3750
# range
3851
latest_within_pin = get_latest_exercise_version_within_pin(version)
39-
ref = f"tags/{latest_within_pin}"
52+
ref = f"tags/v{latest_within_pin}"
4053

4154
url = (
4255
f"https://raw.githubusercontent.com/git-mastery/exercises/refs/{ref}/{filepath}"
4356
)
44-
print(url)
4557
return url
4658

4759

@@ -156,12 +168,22 @@ def get_gitmastery_file_path(path: str):
156168

157169

158170
def fetch_file_contents(url: str, is_binary: bool) -> str | bytes:
171+
web_cache = get_web_cache()
172+
if url in web_cache:
173+
if get_verbose():
174+
print(f"Fetching {url} contents from WEB_CACHE")
175+
return web_cache[url]
159176
response = requests.get(url)
160177

178+
if get_verbose():
179+
print(f"Querying {url} for contents")
180+
161181
if response.status_code == 200:
162182
if is_binary:
163-
return response.content
164-
return response.text
183+
web_cache[url] = response.content
184+
else:
185+
web_cache[url] = response.text
186+
return web_cache[url]
165187
else:
166188
error(
167189
f"Failed to fetch resource {click.style(url, bold=True, italic=True)}. Inform the Git-Mastery team."
@@ -171,12 +193,22 @@ def fetch_file_contents(url: str, is_binary: bool) -> str | bytes:
171193
def fetch_file_contents_or_none(
172194
url: str, is_binary: bool
173195
) -> Optional[Union[str, bytes]]:
196+
web_cache = get_web_cache()
197+
if url in web_cache:
198+
if get_verbose():
199+
print(f"Fetching {url} contents from WEB_CACHE")
200+
return web_cache[url]
174201
response = requests.get(url)
175202

203+
if get_verbose():
204+
print(f"Querying {url} for contents")
205+
176206
if response.status_code == 200:
177207
if is_binary:
178-
return response.content
179-
return response.text
208+
web_cache[url] = response.content
209+
else:
210+
web_cache[url] = response.text
211+
return web_cache[url]
180212
return None
181213

182214

@@ -208,10 +240,14 @@ def get_variable_from_url(
208240

209241
def exercise_exists(exercise: str, timeout: int = 5) -> bool:
210242
try:
243+
exercise_url = get_gitmastery_file_path(
244+
f"{exercise.replace('-', '_')}/.gitmastery-exercise.json"
245+
)
246+
if get_verbose():
247+
print(exercise_url)
248+
211249
response = requests.head(
212-
get_gitmastery_file_path(
213-
f"{exercise.replace('-', '_')}/.gitmastery-exercise.json"
214-
),
250+
exercise_url,
215251
allow_redirects=True,
216252
timeout=timeout,
217253
)

app/utils/version.py

Lines changed: 2 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
from dataclasses import dataclass
2-
from typing import ClassVar, List, Optional, Self, Type
2+
from typing import ClassVar, Optional, Self, Type
33

4-
import requests
5-
import semver # type: ignore
4+
import semver
65

76

87
@dataclass
@@ -160,49 +159,3 @@ def __repr__(self) -> str:
160159
release=False,
161160
development=True,
162161
)
163-
164-
165-
def get_all_exercise_tags() -> List[Version]:
166-
url = "https://api.github.com/repos/git-mastery/exercises/tags"
167-
tags = requests.get(url, timeout=10).json()
168-
return list(
169-
sorted(
170-
[Version.parse_version_string(t["name"].lstrip("v")) for t in tags],
171-
reverse=True,
172-
)
173-
)
174-
175-
176-
def get_latest_release_exercise_version() -> Optional[Version]:
177-
all_tags = get_all_exercise_tags()
178-
if len(all_tags) == 0:
179-
# Although this should not be happening, we will let the callsite handle this
180-
return None
181-
182-
# These should always ignore the development versions, just focus on the release
183-
# versions
184-
for tag in all_tags:
185-
if tag.prerelease is None:
186-
return tag
187-
188-
return None
189-
190-
191-
def get_latest_development_exercise_version() -> Optional[Version]:
192-
all_tags = get_all_exercise_tags()
193-
if len(all_tags) == 0:
194-
# Although this should not be happening, we will let the callsite handle this
195-
return None
196-
197-
for tag in all_tags:
198-
if tag.build is None:
199-
return tag
200-
return None
201-
202-
203-
def get_latest_exercise_version_within_pin(pin_version: Version) -> Optional[Version]:
204-
all_tags = get_all_exercise_tags()
205-
for tag in all_tags:
206-
if tag.within_pin(pin_version):
207-
return tag
208-
return None

tests/utils/test_exercises.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
from typing import List
2+
from unittest import mock
3+
4+
import pytest
5+
from app.utils.exercises import (
6+
get_latest_exercise_version_within_pin,
7+
get_latest_release_exercise_version,
8+
)
9+
from app.utils.version import Version
10+
11+
12+
def test_get_latest_release_exercise_version():
13+
exercises = [
14+
"0.0.1-beta.0",
15+
"0.9.9",
16+
"v1.0.0",
17+
"1.1.1",
18+
"12.1.1-beta.1",
19+
"12.1.1-beta.4",
20+
]
21+
with mock.patch(
22+
"app.utils.version.get_all_exercise_tags", return_value=_get_versions(exercises)
23+
):
24+
assert get_latest_release_exercise_version() == Version(
25+
0, 9, 9, None, None, False, False, False
26+
)
27+
28+
29+
@pytest.mark.parametrize(
30+
"pin,expected",
31+
[
32+
("^1.1.0", Version(1, 1, 1, None, None, False, False, False)),
33+
("^12.0.0", None),
34+
],
35+
)
36+
def test_get_latest_exercise_version_within_pin(pin, expected):
37+
exercises = [
38+
"0.0.1-beta.0",
39+
"0.9.9",
40+
"v1.0.0",
41+
"1.1.1",
42+
"12.1.1-beta.1",
43+
"12.1.1-beta.4",
44+
]
45+
with mock.patch(
46+
"app.utils.version.get_all_exercise_tags",
47+
return_value=_get_versions(exercises),
48+
):
49+
version = get_latest_exercise_version_within_pin(
50+
Version.parse_version_string(pin)
51+
)
52+
assert version == expected
53+
54+
55+
def _get_versions(version_strings: List[str]) -> List[Version]:
56+
return [Version.parse_version_string(v) for v in version_strings]

0 commit comments

Comments
 (0)