Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Sync GitHub metadata #10

Closed
wants to merge 28 commits into from
Closed
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
71fc5cf
Parse project topics
bswck Feb 2, 2024
59da2fa
Allow to overwrite `temp_checkout()` depth
bswck Feb 2, 2024
c6d48de
Load PEP 566 summary in `get_project_metadata()`
bswck Feb 2, 2024
01b07d4
Correct API base url for RTD
bswck Feb 2, 2024
a9b3d7b
Extend RTD facilities
bswck Feb 2, 2024
34d43d7
Wrap GitHub repo metadata API
bswck Feb 2, 2024
2daab1b
Wrap GitHub topics API
bswck Feb 2, 2024
8377a4c
Sanitize topics in payload
bswck Feb 2, 2024
052e682
First strip, then filter assigned topics
bswck Feb 2, 2024
7c9b728
Make `jaraco.develop.github` import in `jaraco.develop.git` lazy
bswck Feb 2, 2024
37b7a72
Add `Repo.from_project()` constructor for correct initialization in f…
bswck Feb 2, 2024
b829293
Add `Repo.url` property
bswck Feb 2, 2024
3748dd2
Create GitHub sync routine
bswck Feb 2, 2024
07f02a9
Guarantee `Project` objects always have `tags` and `topics` attributes
bswck Feb 3, 2024
2bc6f4a
Use f-strings for uniform endpoint URL construction
bswck Feb 3, 2024
ed12aa5
Use a factory constructor for `Project`
bswck Feb 3, 2024
4f12601
Implement `resolve_repo_name()` to find repository names
bswck Feb 3, 2024
2ecc3b2
Use `Project.from_path` for project creation from metadata
bswck Feb 3, 2024
d08afd4
Don't send a PATCH request to a real repo while testing
bswck Feb 3, 2024
6bf98de
Down-cast `Project` value arg before dict lookup
bswck Feb 3, 2024
48cb955
Auto-use `git_url_substitutions`
bswck Feb 4, 2024
c755093
Defer `github.Repo.detect()` execution to on demand
bswck Feb 4, 2024
ff42c34
Supply missing `Project.cache` annotation
bswck Feb 4, 2024
900a0eb
Use `/` instead of `posixpath.sep` in `Project.from_path`
bswck Feb 4, 2024
835b848
Use base names of projects for determining RTD slugs
bswck Feb 4, 2024
c0cd9c8
Add tests to new `Project` utilities
bswck Feb 4, 2024
fae562e
Merge branch 'main' into feat/sync-github-metadata
jaraco Mar 26, 2024
ca80812
Use `@functools.lru_cache` on `Project` factory constructor
bswck Mar 26, 2024
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
2 changes: 1 addition & 1 deletion conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ def pytest_configure():
os.environ['GITHUB_TOKEN'] = 'abc'


@pytest.fixture
@pytest.fixture(autouse=True)
def git_url_substitutions(fake_process):
cmd = ['git', 'config', '--get-regexp', r'url\..*\.insteadof']
stdout = textwrap.dedent(
Expand Down
6 changes: 4 additions & 2 deletions jaraco/develop/add-github-secret.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import autocommand

from . import github


@autocommand.autocommand(__name__)
def run(name, value, project: github.Repo = github.Repo.detect()):
project.add_secret(name, value)
def run(name, value, project: github.Repo | None = None):
(project or github.Repo.detect()).add_secret(name, value)
5 changes: 4 additions & 1 deletion jaraco/develop/add-github-secrets.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from __future__ import annotations

import autocommand
import keyring
import getpass
Expand Down Expand Up @@ -32,7 +34,8 @@ def _safe_getuser():


@autocommand.autocommand(__name__)
def run(project: github.Repo = github.Repo.detect()):
def run(project: github.Repo | None = None):
project = project or github.Repo.detect()
for name in project.find_needed_secrets():
source = secret_sources[name]
value = keyring.get_password(**source)
Expand Down
6 changes: 4 additions & 2 deletions jaraco/develop/create-github-release.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
from __future__ import annotations

import autocommand

from . import github
from . import repo


@autocommand.autocommand(__name__)
def run(project: github.Repo = github.Repo.detect()):
def run(project: github.Repo | None = None):
md = repo.get_project_metadata()
project.create_release(tag=f'v{md.version}')
(project or github.Repo.detect()).create_release(tag=f'v{md.version}')
61 changes: 50 additions & 11 deletions jaraco/develop/git.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
from __future__ import annotations
import contextlib
import functools
import os
import pathlib
import posixpath
import re
import subprocess
import sys
import types
import urllib.parse

import requests
import path
from more_itertools import flatten

from . import github


class URLScheme:
"""
>>> getfixture('git_url_substitutions')
>>> scheme = URLScheme.lookup('gh://foo/bar')
>>> scheme.resolve('gh://foo/bar')
'https://github.com/foo/bar'
Expand Down Expand Up @@ -106,26 +105,54 @@ def path(self):

class Project(str):
"""
>>> p = Project.parse('foo-project [tag1] [tag2]')
>>> p = Project.parse('foo-project [tag1] [tag2] (zero defect, coherent software)')
>>> p
'foo-project'
>>> p.tags
['tag1', 'tag2']
>>> p.topics
['zero defect', 'coherent software']
"""

pattern = re.compile(r'(?P<name>\S+)\s*(?P<rest>.*)$')

def __new__(self, value, **kwargs):
return super().__new__(self, value)
cache: dict[str, Project] = {}

def __new__(cls, value, **kwargs):
# Down-cast to a string early.
value = sys.intern(str(value))
try:
return cls.cache[value]
except KeyError:
new = super().__new__(cls, value)
cls.cache[new] = new
return new
bswck marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self, value, **kwargs):
vars(self).update(kwargs)
vars(self).update({'tags': [], 'topics': [], **kwargs})

@classmethod
def parse(cls, line):
match = types.SimpleNamespace(**cls.pattern.match(line).groupdict())
tags = list(re.findall(r'\[(.*?)\]', match.rest))
return cls(match.name, tags=tags)
tags = list(re.findall(r'\[(.*?)\]', rest := match.rest.rstrip()))
topics_assigned = re.match(r'[^\(\)]*\((.+)\)$', rest)
topics = topics_assigned and map(str.strip, topics_assigned.group(1).split(','))
return cls(match.name, tags=tags, topics=list(filter(None, topics or ())))

@classmethod
def from_path(self, path):
from . import github
local = f'{github.username()}{posixpath.sep}'
if path.startswith(local):
return self(path.removeprefix(local))
return self(posixpath.sep + path.removeprefix(posixpath.sep))
bswck marked this conversation as resolved.
Show resolved Hide resolved

@property
def rtd_slug(self):
return self.replace('.', '').replace('_', '-')

@property
def rtd_url(self):
return f'https://{self.rtd_slug}.readthedocs.io/'


def resolve(name):
Expand All @@ -136,10 +163,21 @@ def resolve(name):
>>> 'gh://pmxbot/pmxbot.nsfw' in projects
True
"""
from . import github
default = URL(f'https://github.com/{github.username()}/')
return default.join(name)


def resolve_repo_name(name):
"""
>>> resolve_repo_name('keyring')
'jaraco/keyring'
>>> resolve_repo_name('/pypa/setuptools')
'pypa/setuptools'
"""
return resolve(name).path.removeprefix(posixpath.sep)


def target_for_root(project, root: path.Path = path.Path()):
"""
Append the prefix of the resolved project name to the target
Expand Down Expand Up @@ -208,7 +246,8 @@ def checkout_missing(project, root):

@contextlib.contextmanager
def temp_checkout(project, **kwargs):
kwargs.setdefault("depth", 50)
with path.TempDir() as dir:
repo = checkout(project, dir, depth=50, **kwargs)
repo = checkout(project, dir, **kwargs)
with repo:
yield
78 changes: 76 additions & 2 deletions jaraco/develop/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
import functools
import re
import pathlib
import posixpath
import itertools

import keyring
import nacl.public
import nacl.encoding
from requests_toolbelt import sessions

from . import git
from . import repo


Expand All @@ -22,6 +24,10 @@ class Repo(str):
def __init__(self, name):
self.session = self.get_session()

@property
def url(self):
return f'https://github.com/{self}'

@classmethod
@functools.lru_cache()
def get_session(cls):
Expand All @@ -42,8 +48,15 @@ def load_token():
return token

@classmethod
def detect(cls):
return cls(repo.get_project_metadata().project)
def from_project(cls, project, *, upstream=False):
if 'fork' in project.tags and not upstream:
return cls(posixpath.sep.join((username(), posixpath.basename(project))))
return cls(git.resolve_repo_name(project))

@classmethod
def detect(cls, *, upstream=False):
project = git.Project.from_path(repo.get_project_metadata().project)
return cls.from_project(project, upstream=upstream)

@functools.lru_cache()
def get_public_key(self):
Expand Down Expand Up @@ -94,6 +107,67 @@ def find_secrets(file):
)
)

def get_metadata(self):
"""
Get repository metadata.

https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository

>>> Repo('jaraco/pip-run').get_metadata()['description']
'pip-run - dynamic dependency loader for Python'
"""
resp = self.session.get(self)
resp.raise_for_status()
return resp.json()

def update_metadata(self, **kwargs):
"""
Update repository metadata (without overwriting existing keys).

Some useful metadata keys:
- name (str)
- description (str)
- homepage (str)
- visibility ("public" or "private")
- has_issues (bool)
- default_branch (str)
- archived (bool)
- allow_forking (bool)

See docs for all of them: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#update-a-repository--parameters

>>> Repo('jaraco/dead-parrot').update_metadata( # doctest: +SKIP
... description="It's no more",
... homepage='https://youtu.be/4vuW6tQ0218',
... )
<Response [200]>
"""
resp = self.session.patch(self, json=kwargs)
resp.raise_for_status()
return resp

def get_topics(self):
"""
Get topics for the repository.

>>> Repo('jaraco/irc').get_topics()
['irc', 'python']
"""
resp = self.session.get(f'{self}/topics')
resp.raise_for_status()
return resp.json()['names']

def set_topics(self, *topics):
"""Completely replace the existing topics with only the given ones."""
names = list(map(str.lower, (topic.replace(' ', '-') for topic in topics)))
resp = self.session.put(f'{self}/topics', json=dict(names=names))
resp.raise_for_status()
return resp

def add_topics(self, *topics):
"""Add new topics to the repository, without removing existing ones."""
return self.set_topics(*self.get_topics(), *topics)


def username():
return os.environ.get('GITHUB_USERNAME') or getpass.getuser()
1 change: 1 addition & 0 deletions jaraco/develop/repo.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ def get_project_metadata():
version = _md['Version']
project = urllib.parse.urlparse(url).path.strip('/')
name = _md['Name']
summary = _md.get('Summary')
return types.SimpleNamespace(**locals())


Expand Down
12 changes: 9 additions & 3 deletions jaraco/develop/rtd.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from requests_toolbelt import sessions


url = 'https://api.readthedocs.org/'
url = 'https://readthedocs.org/'


@functools.lru_cache()
Expand All @@ -15,6 +15,12 @@ def session():
return session


def rtd_exists(project):
return session().head(f'projects/{project.rtd_slug}/').ok


def enable_pr_build(project):
slug = project.replace('.', '').replace('_', '-')
session().patch(f'projects/{slug}/', data=dict(external_builds_enabled=True))
session().patch(
f'projects/{project.rtd_slug}/',
data=dict(external_builds_enabled=True),
)
44 changes: 44 additions & 0 deletions jaraco/develop/sync-github.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
"""
Sync GitHub metadata repo across all projects.

Metadata includes description, homepage (RTD docs if available) and topics.
"""

import autocommand

from . import filters
from . import git
from . import github
from . import repo
from . import rtd


@autocommand.autocommand(__name__)
def main(
keyword: filters.Keyword = None, # type: ignore
tag: filters.Tag = None, # type: ignore
):
for project in filter(tag, filter(keyword, git.projects())):
with git.temp_checkout(project, depth=1):
md = repo.get_project_metadata()
gh = github.Repo.from_project(project)
gh_metadata = gh.get_metadata()
# https://github.com/jaraco/pytest-perf/issues/10#issuecomment-1913669951
# homepage = md.homepage
homepage = (
gh_metadata.get('homepage')
or (project.rtd_url if rtd.rtd_exists(project) else None)
)
description = gh_metadata.get('description') or md.summary
print(f'\n[Metadata for {gh}]')
print('Homepage:', homepage)
print('Description:', description)
print('Topics:', ', '.join(project.topics))
gh.update_metadata(
homepage=homepage,
description=description,
)
print('\nUpdated metadata.')
gh.add_topics(*project.topics)
print('Added topics.\n')
print(gh.url)