Skip to content

Commit

Permalink
Add api function and CLI command to clean up packages. (#13)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhirschfeld authored Oct 27, 2023
1 parent ed97bea commit 6a89a5c
Show file tree
Hide file tree
Showing 4 changed files with 156 additions and 24 deletions.
43 changes: 42 additions & 1 deletion src/eq/devtools/cli/github/packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,45 @@ async def delete(
)
deleted.append(version_id)

print_json(data=dict(deleted=deleted, errors={}))
return dict(deleted=deleted, errors={})


@packages.command(name="cleanup")
@click.option(
"--owner",
type=str,
required=True,
help="The owner of the package to delete.",
)
@click.option(
"--package",
type=str,
required=True,
help="The name of the package to delete.",
)
@click.option(
"--max-age",
type=int,
default=7,
help="The age (in days) after which the package may be deleted.",
)
@click.option(
"--json",
type=str,
is_flag=True,
help="Whether to print the result as JSON.",
)
@run_async
async def cleanup(
owner: str,
package: str,
max_age: int,
):
deleted = await pkgs.cleanup_package_versions(
owner=owner,
package=package,
tags_to_keep=["latest", r"\d+\.\d+\.\d+"],
max_age=max_age,
max_parallel=30,
)
return dict(deleted=deleted, errors={})
6 changes: 4 additions & 2 deletions src/eq/devtools/conda/_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,12 @@ def _render_recipe(
recipe["source"]["path"] = Path(source_path).as_posix()
recipe["build"]["noarch"] = "python"
recipe["build"]["number"] = build_number
script = dedent("""
script = dedent(
"""
set -euxo pipefail
python -m pip install -vv --no-deps --no-build-isolation .
""").lstrip()
"""
).lstrip()
recipe["build"]["script"] = PreservedScalarString(script)
requirements = _parse_requirements()
recipe["requirements"] = {
Expand Down
20 changes: 14 additions & 6 deletions src/eq/devtools/github/api/_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,18 @@
)

import tzlocal
from msgspec import Struct
from msgspec import (
field,
Struct,
)
from rich.repr import Result as RichRepr


TZ_LOCAL = tzlocal.get_localzone()


class HasRichRepr(Protocol):
def __rich_repr__(self) -> RichRepr:
...
def __rich_repr__(self) -> RichRepr: ...


def parse_repr(obj: HasRichRepr) -> Generator[str, None, None]:
Expand Down Expand Up @@ -45,6 +47,9 @@ class _Account(APIObject):
node_id: str
login: str

def __str__(self) -> str:
return self.login

def __repr__(self) -> str:
return f"{self.__class__.__name__}({self.login!r})"

Expand Down Expand Up @@ -86,6 +91,9 @@ class Package(APIObject):
updated_at: DateTime
repository: Repository

def __str__(self) -> str:
return self.name

def __rich_repr__(self) -> RichRepr: # type: ignore
yield self.name
yield "id", self.id
Expand Down Expand Up @@ -130,15 +138,15 @@ class DockerMetadata(_PackageMetadata):

class PackageVersion(APIObject):
id: int
name: str
digest: str = field(name="name")
url: str
created_at: DateTime
updated_at: DateTime
metadata: PackageMetadata
package: str | None = None
package: Package | None = None

def __rich_repr__(self) -> RichRepr: # type: ignore
yield self.package
yield str(self.package)
yield "id", self.id
yield "updated_at", self.updated_at.astimezone(TZ_LOCAL).isoformat()
yield "metadata", self.metadata
111 changes: 96 additions & 15 deletions src/eq/devtools/github/packages.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import re
from datetime import date as Date
from functools import partial
from typing import cast
from urllib.parse import quote_plus

import trio

from .api import (
Package,
PackageVersion,
Expand All @@ -20,47 +25,123 @@


async def list_packages(owner: str, **kwargs) -> list[Package]:
res = await get(
f"orgs/{owner}/packages?package_type=container", model=list[Package], **kwargs
)
# https://docs.github.com/en/rest/packages/packages#list-packages-for-an-organization
kwargs.setdefault("model", list[Package])
res = await get(f"orgs/{owner}/packages?package_type=container", **kwargs)
return cast(list[Package], res)


async def get_package(owner: str, package: str, **kwargs) -> Package:
# https://docs.github.com/en/rest/packages/packages#get-a-package-for-an-organization
kwargs.setdefault("model", Package)
res = await get(
f"orgs/{owner}/packages/container/{quote_plus(package)}",
model=Package,
**kwargs,
)
return cast(Package, res)


async def list_package_versions(
owner: str, package: str, **kwargs
owner: str,
package: str,
**kwargs,
) -> list[PackageVersion]:
# https://docs.github.com/en/rest/packages/packages#list-package-versions-for-a-package-owned-by-an-organization
model = kwargs.pop("model", None)
has_model = model is not None
model = model or list[PackageVersion]
res = await get(
f"/orgs/{owner}/packages/container/{quote_plus(package)}/versions",
model=list[PackageVersion],
model=model,
**kwargs,
)
if has_model:
# if a custom model is specified, don't set the package attribute
return res

package_obj = await get_package(owner, package, **kwargs)
for package_version in res:
package_version.package = package
package_version.package = package_obj

return cast(list[PackageVersion], res)


def _get_versions(self) -> list[PackageVersion]:
return list_package_versions(self.owner.login, self.name)


# Monkey-patch Package to add versions property
setattr(Package, "versions", property(_get_versions))


async def delete_package_version(
owner: str, package: str, *, version_id: int, **kwargs
) -> None:
# https://docs.github.com/en/rest/packages/packages#delete-package-version-for-an-organization
await delete(
f"/orgs/{owner}/packages/container/{quote_plus(package)}/versions/{version_id:d}",
**kwargs,
)


async def _get_versions(self) -> list[PackageVersion]:
return await list_package_versions(self.owner.login, self.name)


# Monkey-patch Package to add versions property
setattr(Package, "versions", property(_get_versions))


# Monkey-patch PackageVersion to add delete method
async def _delete(self) -> None:
await delete_package_version(
self.package.owner,
self.package.name,
version_id=self.id,
)


PackageVersion.delete = _delete


async def delete_package_versions(
*package_versions: PackageVersion,
max_parallel: int = 30,
) -> list[PackageVersion]:
# FIXME: handle errors with outcome
limiter = trio.CapacityLimiter(max_parallel)

async def _delete(
package_version: PackageVersion,
*,
limiter: trio.CapacityLimiter,
) -> None:
async with limiter:
await package_version.delete()

async with trio.open_nursery() as nursery:
for package_version in package_versions:
nursery.start_soon(partial(_delete, limiter=limiter), package_version)

return list(package_versions)


async def cleanup_package_versions(
owner: str,
package: str,
*,
tags_to_keep: list[str] = ["latest", r"\d+\.\d+\.\d+"],
max_age: int = 7,
max_parallel: int = 30,
**kwargs,
) -> list[PackageVersion]:
tags_to_keep = re.compile(f"^({'|'.join(tags_to_keep)})$")
today = Date.today()

def has_expired(pv: PackageVersion) -> bool:
age: int = (today - pv.updated_at.date()).days
return age > max_age

versions_to_delete = [
package_version
for package_version in await list_package_versions(owner, package, **kwargs)
if has_expired(package_version)
and not any(map(tags_to_keep.match, package_version.metadata.tags))
]
deleted = await delete_package_versions(
*versions_to_delete,
max_parallel=max_parallel,
)
return deleted

0 comments on commit 6a89a5c

Please sign in to comment.