Skip to content

Commit 877761a

Browse files
Initial commit
0 parents  commit 877761a

22 files changed

Lines changed: 1940 additions & 0 deletions

.github/__init__.py

Whitespace-only changes.

.github/actions.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
import os
2+
import copy
3+
import re
4+
import shutil
5+
6+
from bs4 import BeautifulSoup
7+
8+
9+
INDEX_FILE = "index.html"
10+
TEMPLATE_FILE = "pkg_template.html"
11+
YAML_ACTION_FILES = [".github/workflows/delete.yml", ".github/workflows/update.yml"]
12+
13+
INDEX_CARD_HTML = '''
14+
<a class="card" href="">
15+
placeholder_name
16+
<span>
17+
</span>
18+
<span class="version">
19+
placehholder_version
20+
</span>
21+
<br/>
22+
<span class="description">
23+
placeholder_description
24+
</span>
25+
</a>'''
26+
27+
28+
def normalize(name):
29+
""" From PEP503 : https://www.python.org/dev/peps/pep-0503/ """
30+
return re.sub(r"[-_.]+", "-", name).lower()
31+
32+
33+
def normalize_version(version):
34+
version = version.lower()
35+
return version[1:] if version.startswith("v") else version
36+
37+
38+
def is_stable(version):
39+
return not ("dev" in version or "a" in version or "b" in version or "rc" in version)
40+
41+
42+
def package_exists(soup, package_name):
43+
package_ref = package_name + "/"
44+
for anchor in soup.find_all('a'):
45+
if anchor['href'] == package_ref:
46+
return True
47+
return False
48+
49+
50+
def transform_github_url(input_url):
51+
# Split the input URL to extract relevant information
52+
parts = input_url.rstrip('/').split('/')
53+
username, repo = parts[-2], parts[-1]
54+
55+
# Create the raw GitHub content URL
56+
raw_url = f'https://raw.githubusercontent.com/{username}/{repo}/main/README.md'
57+
return raw_url
58+
59+
60+
def register(pkg_name, version, author, short_desc, homepage):
61+
link = f'git+{homepage}@{version}'
62+
long_desc = transform_github_url(homepage)
63+
# Read our index first
64+
with open(INDEX_FILE) as html_file:
65+
soup = BeautifulSoup(html_file, "html.parser")
66+
norm_pkg_name = normalize(pkg_name)
67+
norm_version = normalize_version(version)
68+
69+
if package_exists(soup, norm_pkg_name):
70+
raise ValueError(f"Package {norm_pkg_name} seems to already exists")
71+
72+
# Create a new anchor element for our new package
73+
placeholder_card = BeautifulSoup(INDEX_CARD_HTML, 'html.parser')
74+
placeholder_card = placeholder_card.find('a')
75+
new_package = copy.copy(placeholder_card)
76+
new_package['href'] = f"{norm_pkg_name}/"
77+
new_package.contents[0].replace_with(pkg_name)
78+
spans = new_package.find_all('span')
79+
spans[1].string = norm_version # First span contain the version
80+
spans[2].string = short_desc # Second span contain the short description
81+
82+
# Add it to our index and save it
83+
soup.find('h6', class_='text-header').insert_after(new_package)
84+
with open(INDEX_FILE, 'wb') as index:
85+
index.write(soup.prettify("utf-8"))
86+
87+
# Then get the template, replace the content and write to the right place
88+
with open(TEMPLATE_FILE) as temp_file:
89+
template = temp_file.read()
90+
91+
template = template.replace("_package_name", pkg_name)
92+
template = template.replace("_norm_version", norm_version)
93+
template = template.replace("_version", version)
94+
template = template.replace("_link", f"{link}#egg={norm_pkg_name}-{norm_version}")
95+
template = template.replace("_homepage", homepage)
96+
template = template.replace("_author", author)
97+
template = template.replace("_long_description", long_desc)
98+
template = template.replace("_latest_main", version)
99+
100+
os.mkdir(norm_pkg_name)
101+
package_index = os.path.join(norm_pkg_name, INDEX_FILE)
102+
with open(package_index, "w") as f:
103+
f.write(template)
104+
105+
106+
def update(pkg_name, version):
107+
# Read our index first
108+
with open(INDEX_FILE) as html_file:
109+
soup = BeautifulSoup(html_file, "html.parser")
110+
norm_pkg_name = normalize(pkg_name)
111+
norm_version = normalize_version(version)
112+
113+
if not package_exists(soup, norm_pkg_name):
114+
raise ValueError(f"Package {norm_pkg_name} seems to not exists")
115+
116+
# Change the version in the main page (only if stable)
117+
if is_stable(version):
118+
anchor = soup.find('a', attrs={"href": f"{norm_pkg_name}/"})
119+
spans = anchor.find_all('span')
120+
spans[1].string = norm_version
121+
with open(INDEX_FILE, 'wb') as index:
122+
index.write(soup.prettify("utf-8"))
123+
124+
# Change the package page
125+
index_file = os.path.join(norm_pkg_name, INDEX_FILE)
126+
with open(index_file) as html_file:
127+
soup = BeautifulSoup(html_file, "html.parser")
128+
129+
# Extract the URL from the onclick attribute
130+
button = soup.find('button', id='repoHomepage')
131+
if button:
132+
link = button.get("onclick")[len("openLinkInNewTab('"):-2]
133+
else:
134+
raise Exception("Homepage URL not found")
135+
136+
# Create a new anchor element for our new version
137+
original_div = soup.find('section', class_='versions').findAll('div')[-1]
138+
new_div = copy.copy(original_div)
139+
anchor = new_div.find('a')
140+
new_div['onclick'] = f"load_readme('{version}', scroll_to_div=true);"
141+
new_div['id'] = version
142+
new_div['class'] = ""
143+
if not is_stable(version):
144+
new_div['class'] += "prerelease"
145+
else:
146+
# replace the latest main version
147+
main_version_span = soup.find('span', id='latest-main-version')
148+
main_version_span.string = version
149+
anchor.string = norm_version
150+
anchor['href'] = f"git+{link}@{version}#egg={norm_pkg_name}-{norm_version}"
151+
152+
# Add it to our index
153+
original_div.insert_after(new_div)
154+
155+
# Change the latest version (if stable)
156+
if is_stable(version):
157+
soup.html.body.div.section.find_all('span')[1].contents[0].replace_with(version)
158+
159+
# Save it
160+
with open(index_file, 'wb') as index:
161+
index.write(soup.prettify("utf-8"))
162+
163+
164+
def delete(pkg_name):
165+
# Read our index first
166+
with open(INDEX_FILE) as html_file:
167+
soup = BeautifulSoup(html_file, "html.parser")
168+
norm_pkg_name = normalize(pkg_name)
169+
170+
if not package_exists(soup, norm_pkg_name):
171+
raise ValueError(f"Package {norm_pkg_name} seems to not exists")
172+
173+
# Remove the package directory
174+
shutil.rmtree(norm_pkg_name)
175+
176+
# Find and remove the anchor corresponding to our package
177+
anchor = soup.find('a', attrs={"href": f"{norm_pkg_name}/"})
178+
anchor.extract()
179+
with open(INDEX_FILE, 'wb') as index:
180+
index.write(soup.prettify("utf-8"))
181+
182+
183+
def main():
184+
# Call the right method, with the right arguments
185+
action = os.environ["PKG_ACTION"]
186+
187+
if action == "REGISTER":
188+
register(
189+
pkg_name=os.environ["PKG_NAME"],
190+
version=os.environ["PKG_VERSION"],
191+
author=os.environ["PKG_AUTHOR"],
192+
short_desc=os.environ["PKG_SHORT_DESC"],
193+
homepage=os.environ["PKG_HOMEPAGE"],
194+
)
195+
elif action == "DELETE":
196+
delete(
197+
pkg_name=os.environ["PKG_NAME"]
198+
)
199+
elif action == "UPDATE":
200+
update(
201+
pkg_name=os.environ["PKG_NAME"],
202+
version=os.environ["PKG_VERSION"]
203+
)
204+
205+
206+
if __name__ == "__main__":
207+
main()

.github/tests.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import subprocess
2+
import os
3+
from importlib.metadata import PackageNotFoundError, version
4+
from contextlib import contextmanager
5+
6+
7+
@contextmanager
8+
def run_local_pypi_index():
9+
# WARNING : This requires the script to be run from the root of the repo
10+
p = subprocess.Popen(["python", "-m", "http.server"])
11+
try:
12+
yield
13+
finally:
14+
p.terminate()
15+
16+
17+
def exists(pkg_name: str) -> bool:
18+
try:
19+
version(pkg_name)
20+
except PackageNotFoundError:
21+
return False
22+
else:
23+
return True
24+
25+
26+
def pip_install(pkg_name: str, upgrade: bool = False, version: str = None):
27+
package_to_install = pkg_name if version is None else f"{pkg_name}=={version}"
28+
cmd = ["python", "-m", "pip", "install", package_to_install, "--upgrade" if upgrade else "", "--extra-index-url", "http://localhost:8000"]
29+
subprocess.run([c for c in cmd if c])
30+
31+
32+
def pip_uninstall(pkg_name: str):
33+
subprocess.run(["python", "-m", "pip", "uninstall", pkg_name, "-y"])
34+
35+
36+
def register(pkg_name: str, pkg_version: str, pkg_link: str):
37+
env = os.environ.copy()
38+
env["PKG_ACTION"] = "REGISTER"
39+
env["PKG_NAME"] = pkg_name
40+
env["PKG_VERSION"] = pkg_version
41+
env["PKG_AUTHOR"] = "Dummy author"
42+
env["PKG_SHORT_DESC"] = "Dummy Description"
43+
env["PKG_HOMEPAGE"] = pkg_link
44+
subprocess.run(["python", ".github/actions.py"], env=env)
45+
46+
47+
def update(pkg_name: str, pkg_version: str):
48+
env = os.environ.copy()
49+
env["PKG_ACTION"] = "UPDATE"
50+
env["PKG_NAME"] = pkg_name
51+
env["PKG_VERSION"] = pkg_version
52+
subprocess.run(["python", ".github/actions.py"], env=env)
53+
54+
55+
def delete(pkg_name: str):
56+
env = os.environ.copy()
57+
env["PKG_ACTION"] = "DELETE"
58+
env["PKG_NAME"] = pkg_name
59+
subprocess.run(["python", ".github/actions.py"], env=env)
60+
61+
62+
def run_tests():
63+
# This test is checking that the Github actions for registering, updating,
64+
# and deleting are working and the PyPi index is updated accordingly.
65+
# What we do is :
66+
# * Serve the HTML locally so we have a local PyPi index
67+
# * Run the actions for registering, updating, and deleting packages in
68+
# this local PyPi
69+
# * In-between, run some basic checks to see if the installation is
70+
# working properly
71+
with run_local_pypi_index():
72+
# First, make sure the package is not installed
73+
assert not exists("public-hello")
74+
75+
# The package `public-hello` is already registered in our local PyPi
76+
# ACTION : Let's remove it from our local PyPi
77+
delete("public-hello")
78+
79+
# Trying to install the package, only the version uploaded to PyPi (0.0.0)
80+
# will be detected and installed (the version in our local PyPi was
81+
# successfully deleted)
82+
pip_install("public-hello")
83+
assert exists("public-hello") and version("public-hello") == "0.0.0"
84+
85+
# ACTION : Register the package, to make it available again
86+
register("public-hello", "0.1", "https://github.com/astariul/public-hello")
87+
88+
# Now we can install it from the local PyPi
89+
pip_install("public-hello", upgrade=True)
90+
assert exists("public-hello") and version("public-hello") == "0.1"
91+
92+
# ACTION : Update the package with a new version
93+
update("public-hello", "0.2")
94+
95+
# We can update the package to the newest version
96+
pip_install("public-hello", upgrade=True)
97+
assert exists("public-hello") and version("public-hello") == "0.2"
98+
99+
# We should still be able to install the old version
100+
pip_install("public-hello", version="0.1")
101+
assert exists("public-hello") and version("public-hello") == "0.1"
102+
103+
# Uninstall the package (for consistency with the initial state)
104+
pip_uninstall("public-hello")
105+
assert not exists("public-hello")
106+
107+
108+
if __name__ == "__main__":
109+
run_tests()

.github/workflows/delete.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: delete
2+
3+
on:
4+
workflow_dispatch:
5+
inputs:
6+
package_name:
7+
description: Package name
8+
required: true
9+
type: string
10+
11+
jobs:
12+
delete:
13+
runs-on: ubuntu-latest
14+
strategy:
15+
matrix:
16+
python-version: [3.8]
17+
18+
steps:
19+
- uses: actions/checkout@v2
20+
- name: Set up Python ${{ matrix.python-version }}
21+
uses: actions/setup-python@v1
22+
with:
23+
python-version: ${{ matrix.python-version }}
24+
- name: Run Action
25+
env:
26+
PKG_ACTION: DELETE
27+
PKG_NAME: ${{ inputs.package_name }}
28+
run: |
29+
pip install beautifulsoup4
30+
python .github/actions.py
31+
- name: Create Pull Request
32+
uses: peter-evans/create-pull-request@v3
33+
with:
34+
commit-message: ':package: [:robot:] Delete package from PyPi index'
35+
title: '[🤖] Delete `${{ inputs.package_name }}` from PyPi index'
36+
body: Automatically generated PR, deleting `${{ inputs.package_name }}` from
37+
PyPi index.
38+
branch-suffix: random
39+
delete-branch: true

0 commit comments

Comments
 (0)