diff --git a/src/integration_test/test.sh b/integration_test/test.sh similarity index 95% rename from src/integration_test/test.sh rename to integration_test/test.sh index a571973..4a8227a 100644 --- a/src/integration_test/test.sh +++ b/integration_test/test.sh @@ -37,7 +37,7 @@ run_basic_commands() { "releases -r $REPO" "pypi -r $REPO" "audit-releases -r $REPO" - "audit-repo -r $REPO" + "audit-rulesets -r $REPO" "audit-versions -r $REPO" ) @@ -59,7 +59,7 @@ run_pretty_commands() { "releases -r $REPO" "pypi -r $REPO" "audit-releases -r $REPO" - "audit-repo -r $REPO" + "audit-rulesets -r $REPO" "audit-versions -r $REPO" ) @@ -81,7 +81,7 @@ run_pretty_json_commands() { "releases -r $REPO" "pypi -r $REPO" "audit-releases -r $REPO" - "audit-repo -r $REPO" + "audit-rulesets -r $REPO" "audit-versions -r $REPO" ) diff --git a/pyproject.toml b/pyproject.toml index 8292b15..e02d7d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ dev = [ "poethepoet>=0.30.0", "types-tabulate>=0.9.0.20241207", "types-aiofiles>=24.1.0.20250326", + "ipdb>=0.13.13", ] #docs = [ diff --git a/src/github_helper/_adapters/api_to_cli.py b/src/github_helper/_adapters/api_to_cli.py index 4fc4db9..4cefa5a 100644 --- a/src/github_helper/_adapters/api_to_cli.py +++ b/src/github_helper/_adapters/api_to_cli.py @@ -1,4 +1,5 @@ import urllib.parse +from pprint import pformat import logistro @@ -12,11 +13,12 @@ class GHAdapter: """Allows the CLI to transform the data as required.""" def _check_options(self, formatters): + allowed_commands = {"repos", "audit-rulesets"} formats = {k for k, v in formatters.items() if v} invalid_formats = {"html", "url"} & formats if ( (self._command == "auth-status" and formats) - or (self._command != "repos" and invalid_formats) + or (self._command not in allowed_commands and invalid_formats) or (self._command == "audit-releases" and self._json) ): raise NotImplementedError( @@ -74,7 +76,7 @@ async def transform_scopes_data(self, scopes_data): async def transform_repos_data(self, repos_data): if self._html: - generated_html = str(await to_html.repos(repos_data)) + generated_html = str(await to_html.repos_template(repos_data)) if not self._url: return generated_html encoded = urllib.parse.quote(generated_html) @@ -168,10 +170,23 @@ async def transform_audit_releases_data(self, releases_data): ) async def transform_audit_rulesets_data(self, audit_rulesets_data): + if self._html: + generated_html = str( + await to_html.rulesets_template(audit_rulesets_data), + ) + if not self._url: + return generated_html + encoded = urllib.parse.quote(generated_html) + return f"data:text/html;charset=utf-8,{encoded}" + if self._json: return to_json.format_json(audit_rulesets_data, pretty=self._pretty) - for rule in audit_rulesets_data: - rule["template"] = rule["template"][:24] + + audit_rulesets_data["diffs"] = [ + pformat(d) if not isinstance(d, str) else d + for d in audit_rulesets_data.get("diffs") + ] + return to_table.format_table(audit_rulesets_data, pretty=self._pretty) async def transform_audit_versions_data(self, version_data): diff --git a/src/github_helper/_adapters/to_html/__init__.py b/src/github_helper/_adapters/to_html/__init__.py index 760d41b..b0231b4 100644 --- a/src/github_helper/_adapters/to_html/__init__.py +++ b/src/github_helper/_adapters/to_html/__init__.py @@ -1,198 +1,4 @@ -from collections.abc import MutableMapping -from dataclasses import dataclass -from pathlib import Path +from .repos import repos_template +from .rulesets import rulesets_template -import logistro -from htmy import Component, Context, component, html - -from github_helper._adapters.to_html import _components -from github_helper._utils import load_file - -_logger = logistro.getLogger(__name__) - -github_com = r"https://www.github.com" -_HTML_DIR = Path(__file__).resolve().parent -_STYLES_PATH = _HTML_DIR / "_styles" -_JS_PATH = _HTML_DIR / "_js" - - -@dataclass(frozen=True, kw_only=True, slots=True) -class RepoRow: - repo: MutableMapping - - def _error_printer(self, s): - return html.span(str(type(s).__name__), title=str(s)) - - async def htmy(self, context: Context) -> Component: # noqa: ARG002 - repo = self.repo - permission_msg = { - 0: "Can read and clone this repository.", - 1: "Can pull and also manage issues and pull requests.", - 2: "Can read, clone, and push to this repository", - 3: "Can also manage issues, pull requests, and some repository settings.", - 4: "Full access to the repository, including settings and collaborators.", - } - - _logger.debug(f"Building html row for {repo['name']}") - return html.tr( - html.td(html.span("📌" if repo["pinned"] else "")), - html.td( - html.a( - repo["owner"], - href=f"{github_com}/{repo['owner']}", - target="_blank", - ), - class_="owner", - ), - html.td(html.span("/")), - html.td( - html.a( - repo["name"], - href=f"{github_com}/{repo['owner']}/{repo['name']}", - target="_blank", - ), - class_="repo", - ), - html.td(html.span(repo["version"]), class_="text-center"), - html.td(html.span("⑂" if repo["fork"] else "")), - html.td( - html.span(repo["description"] or ""), - class_="description", - ), - html.td( - html.a( - "🔧", - href=f"{github_com}/{repo['owner']}/{repo['name']}/settings/access", - target="_blank", - ), - *[ - self._error_printer(s) - if isinstance(s, Exception) - else html.a( - s["user"], - html.sup( - str(s["permission"]), - class_="badge", - ), - href=f"{github_com}/{s['user']}", - class_="collaborator", - target="_blank", - title=permission_msg[s["permission"]], - ) - if isinstance(s, dict) - else s - for s in ( - sorted( - repo["collaborators"], - key=lambda d: d["permission"], - reverse=True, - ) - if repo["collaborators"] - and isinstance(repo["collaborators"][0], dict) - else repo["collaborators"] - ) - ], - class_="collaborators", - ), - html.td( - html.a( - "🔧", - href=f"{github_com}/{repo['owner']}/{repo['name']}", - target="_blank", - ), - *[html.span(s, class_=f"topic {s}") for s in repo["topics"]], - class_="topics", - ), - class_=( - "repo-row " - f"{repo['visibility']} " - f"{'archived' if repo['archived'] else ''}" - ), - ) - - -@component -def repo_rows(repos, context: Context) -> Component: # noqa: ARG001 - return [RepoRow(repo=repo) for repo in repos] - - -async def repos(repos_data): - _logger.debug("Building table.") - styles = html.style(await load_file(_STYLES_PATH / "repos.css")) - table = html.table(repo_rows(repos_data), class_="mx-auto") - modal_iframe = _components.modal( - "my-modal", - "closeModal()", - html.iframe(src="", height="500", class_="w-full"), - ) - _logger.debug("Building page.") - scripts = [ - html.script( - html.SafeStr( - "\n".join( - [ - await load_file(_JS_PATH / "repos.js"), - await load_file(_JS_PATH / "modal.js"), - ], - ), - ), - ), - ] - content = [ - html.div( - html.label( - html.input_( - type_="checkbox", - id_="toggle-public", - checked=True, - ), - " Show Public", - ), - html.label( - html.input_( - type_="checkbox", - id_="toggle-private", - checked=True, - style="margin-left:1rem;", - ), - " Show Private", - ), - html.label( - html.input_( - type_="checkbox", - id_="toggle-archive", - checked=True, - style="margin-left:1rem;", - ), - " Show Archived", - ), - html.br(), - html.label( - " Owner", - html.input_( - type_="text", - id_="owner-filter", - name="owner-filter", - placeholder="Owner", - class_="rounded shadow-sm sm:text-sm p-1", - ), - ), - html.label( - " Repo", - html.input_( - type_="text", - id_="repo-filter", - name="repo-filter", - placeholder="Repo", - class_="rounded shadow-sm sm:text-sm p-1", - ), - ), - style="margin-bottom: 1rem;", - class_="mx-auto", - id_="controls", - ), - table, - modal_iframe, - *scripts, - ] - return await _components.render_page([styles], content) +__all__ = ["repos_template", "rulesets_template"] diff --git a/src/github_helper/_adapters/to_html/_js/repos.js b/src/github_helper/_adapters/to_html/_js/repos.js index e5d439a..3d20d77 100644 --- a/src/github_helper/_adapters/to_html/_js/repos.js +++ b/src/github_helper/_adapters/to_html/_js/repos.js @@ -3,6 +3,7 @@ const privateCheckbox = document.getElementById('toggle-private'); const archivedCheckbox = document.getElementById('toggle-archive'); const ownerInput = document.getElementById('owner-filter'); const repoInput = document.getElementById('repo-filter'); +const tbody = document.querySelector("tbody"); function filterAll() { console.log("Filtering All.") diff --git a/src/github_helper/_adapters/to_html/_js/utils.js b/src/github_helper/_adapters/to_html/_js/utils.js new file mode 100644 index 0000000..95ba008 --- /dev/null +++ b/src/github_helper/_adapters/to_html/_js/utils.js @@ -0,0 +1,21 @@ +const sortTableBy = (btnId, filter) => { + const filterBtn = document.getElementById(btnId); + const order = filterBtn.dataset.order === "asc" ? "desc" : "asc"; + filterBtn.dataset.order = order; + isAsc = order === "asc" + filterBtn.textContent = `${isAsc ? "⬆️" : "⬇️"}`; + filterBtn.title = `Sort by ${isAsc ? "asc" : "desc"}`; + + const rows = Array.from(tbody.querySelectorAll("tr")); + + rows.sort((a, b) => { + const nameA = a.querySelector(filter).textContent.trim().toLowerCase(); + const nameB = b.querySelector(filter).textContent.trim().toLowerCase(); + return order === "asc" + ? nameA.localeCompare(nameB) + : nameB.localeCompare(nameA); + }); + + rows.forEach(row => tbody.appendChild(row)); + updateVisibleRowClasses(); +} diff --git a/src/github_helper/_adapters/to_html/_styles/common.css b/src/github_helper/_adapters/to_html/_styles/common.css new file mode 100644 index 0000000..9eac68e --- /dev/null +++ b/src/github_helper/_adapters/to_html/_styles/common.css @@ -0,0 +1,28 @@ +body { + overflow-x: auto; + width: 100%; +} + +table { + width: max-content; +} + +tr.even { + background-color: #f0f0f0; +} + +tr.odd { + background-color: #ffffff; +} + +.table { + @apply table-auto mx-auto my-4 border border-gray-300 shadow-md rounded-md overflow-hidden text-sm; +} + +.table-header { + @apply text-center px-4 py-2 text-sm font-semibold text-gray-700 bg-gray-300 border-r; +} + +.table-col { + @apply px-2 py-2 align-top border-t border-gray-200 border-r; +} diff --git a/src/github_helper/_adapters/to_html/_styles/repos.css b/src/github_helper/_adapters/to_html/_styles/repos.css index 77dd063..0cea605 100644 --- a/src/github_helper/_adapters/to_html/_styles/repos.css +++ b/src/github_helper/_adapters/to_html/_styles/repos.css @@ -26,23 +26,6 @@ tr.repo-row td a:active { color: black; } -body { - overflow-x: auto; - width: 100%; -} - -table { - width: max-content; -} - -tr.even { - background-color: #f0f0f0; -} - -tr.odd { - background-color: #ffffff; -} - tr.repo-row.private { font-weight: 350; } diff --git a/src/github_helper/_adapters/to_html/repos.py b/src/github_helper/_adapters/to_html/repos.py new file mode 100644 index 0000000..9f9f21c --- /dev/null +++ b/src/github_helper/_adapters/to_html/repos.py @@ -0,0 +1,242 @@ +from collections.abc import MutableMapping +from dataclasses import dataclass +from pathlib import Path + +import logistro +from htmy import Component, Context, component, html + +from github_helper._adapters.to_html import _components +from github_helper._utils import load_file + +_logger = logistro.getLogger(__name__) + +github_com = r"https://www.github.com" +_HTML_DIR = Path(__file__).resolve().parent +_STYLES_PATH = _HTML_DIR / "_styles" +_JS_PATH = _HTML_DIR / "_js" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class RepoRow: + repo: MutableMapping + + def _error_printer(self, s): + return html.span(str(type(s).__name__), title=str(s)) + + async def htmy(self, context: Context) -> Component: # noqa: ARG002 + repo = self.repo + permission_msg = { + 0: "Can read and clone this repository.", + 1: "Can pull and also manage issues and pull requests.", + 2: "Can read, clone, and push to this repository", + 3: "Can also manage issues, pull requests, and some repository settings.", + 4: "Full access to the repository, including settings and collaborators.", + } + + _logger.debug(f"Building html row for {repo['name']}") + return html.tr( + html.td(html.span("📌" if repo["pinned"] else "")), + html.td( + html.a( + repo["owner"], + href=f"{github_com}/{repo['owner']}", + target="_blank", + ), + class_="owner", + ), + html.td(html.span("/")), + html.td( + html.a( + repo["name"], + href=f"{github_com}/{repo['owner']}/{repo['name']}", + target="_blank", + ), + class_="repo border-gray-200 border-r pr-2", + ), + html.td( + html.span(repo["version"]), + class_="text-center", + ), + html.td( + html.span("⑂" if repo["fork"] else ""), + class_="table-col", + ), + html.td( + html.span(repo["description"] or ""), + class_="description table-col", + ), + html.td( + html.a( + "🔧", + href=f"{github_com}/{repo['owner']}/{repo['name']}/settings/access", + target="_blank", + ), + *[ + self._error_printer(s) + if isinstance(s, Exception) + else html.a( + s["user"], + html.sup( + str(s["permission"]), + class_="badge", + ), + href=f"{github_com}/{s['user']}", + class_="collaborator bg-white hover:bg-slate-200", + target="_blank", + title=permission_msg[s["permission"]], + ) + if isinstance(s, dict) + else s + for s in ( + sorted( + repo["collaborators"], + key=lambda d: d["permission"], + reverse=True, + ) + if repo["collaborators"] + and isinstance(repo["collaborators"][0], dict) + else repo["collaborators"] + ) + ], + class_="collaborators table-col", + ), + html.td( + html.a( + "🔧", + href=f"{github_com}/{repo['owner']}/{repo['name']}", + target="_blank", + ), + *[html.span(s, class_=f"topic {s} bg-white") for s in repo["topics"]], + class_="topics table-col", + ), + class_=( + "repo-row " + f"{repo['visibility']} " + f"{'archived' if repo['archived'] else ''}" + ), + ) + + +@component +def repo_rows(repos, context: Context) -> Component: # noqa: ARG001 + return [RepoRow(repo=repo) for repo in repos] + + +async def repos_template(repos_data): + _logger.debug("Building table.") + styles = [ + html.style( + await load_file(_STYLES_PATH / "common.css"), + type="text/tailwindcss", + ), + html.style(await load_file(_STYLES_PATH / "repos.css")), + ] + table = html.table( + html.thead( + html.tr( + html.th( + html.div( + html.span("Repository"), + html.span( + "⬆️", + id_="sort-repo", + class_="cursor-pointer", + data_order="asc", + onclick="sortTableBy('sort-repo', '.repo')", + title="Sort by asc", + ), + ), + colspan=4, + class_="table-header", + ), + html.th("Head Tag", colspan=2, class_="table-header"), + html.th("Description", colspan=1, class_="table-header"), + html.th("Collaborators", colspan=1, class_="table-header"), + html.th("Topics", colspan=1, class_="table-header"), + ), + ), + html.tbody(repo_rows(repos_data)), + class_="table", + ) + modal_iframe = _components.modal( + "my-modal", + "closeModal()", + html.iframe(src="", height="500", class_="w-full"), + ) + _logger.debug("Building page.") + scripts = [ + html.script( + html.SafeStr( + "\n".join( + [ + await load_file(_JS_PATH / "repos.js"), + await load_file(_JS_PATH / "modal.js"), + await load_file(_JS_PATH / "utils.js"), + ], + ), + ), + ), + ] + content = [ + html.div( + html.div( + html.div( + html.label( + html.input_( + type_="checkbox", + id_="toggle-public", + checked=True, + ), + " Show Public", + ), + html.label( + html.input_( + type_="checkbox", + id_="toggle-private", + checked=True, + ), + " Show Private", + ), + html.label( + html.input_( + type_="checkbox", + id_="toggle-archive", + checked=True, + ), + " Show Archived", + ), + class_="flex justify-between", + ), + html.div( + html.label( + " Owner", + html.input_( + type_="text", + id_="owner-filter", + name="owner-filter", + placeholder="Owner", + class_="rounded shadow-sm p-1", + ), + ), + html.label( + " Repo", + html.input_( + type_="text", + id_="repo-filter", + name="repo-filter", + placeholder="Repo", + class_="rounded shadow-sm p-1", + ), + ), + class_="flex justify-between", + ), + class_="mx-auto", + id_="controls", + ), + class_="sticky top-0 w-full bg-white p-2 z-10", + ), + table, + modal_iframe, + *scripts, + ] + return await _components.render_page([*styles], content) diff --git a/src/github_helper/_adapters/to_html/rulesets.py b/src/github_helper/_adapters/to_html/rulesets.py new file mode 100644 index 0000000..3a0a232 --- /dev/null +++ b/src/github_helper/_adapters/to_html/rulesets.py @@ -0,0 +1,88 @@ +from collections.abc import MutableMapping +from dataclasses import dataclass +from pathlib import Path + +import logistro +from htmy import Component, Context, component, html + +from github_helper._adapters import to_json +from github_helper._adapters.to_html import _components +from github_helper._utils import load_file + +_logger = logistro.getLogger(__name__) + +_HTML_DIR = Path(__file__).resolve().parent +_STYLES_PATH = _HTML_DIR / "_styles" +_HLJS_URL = "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.11.1" + + +@dataclass(frozen=True, kw_only=True, slots=True) +class RulesetRow: + ruleset: MutableMapping + + def _error_printer(self, s): + return html.span(str(type(s).__name__), title=str(s)) + + async def htmy(self, context: Context) -> Component: # noqa: ARG002 + ruleset = self.ruleset + return html.tr( + html.td(ruleset["enabled_ruleset"] or "", class_="table-col"), + html.td(ruleset["desired_ruleset"] or "", class_="table-col"), + html.td( + html.pre( + html.code( + to_json.format_json(ruleset["diff"], pretty=True), + class_="language-json rounded-md", + ), + ) + if isinstance(ruleset["diff"], dict) + else ruleset["diff"], + class_="table-col", + ), + ) + + +@component +def rulesets_rows(rulesets, context: Context) -> Component: # noqa: ARG001 + return [ + RulesetRow( + ruleset={ + "enabled_ruleset": rulesets["enabled rulesets"][r], + "desired_ruleset": rulesets["desired rulesets"][r], + "diff": rulesets["diffs"][r], + }, + ) + for r in range(len(rulesets["enabled rulesets"])) + ] + + +async def rulesets_template(rulesets_data): + _logger.debug("Building table.") + styles = [ + html.style( + await load_file(_STYLES_PATH / "common.css"), + type="text/tailwindcss", + ), + html.link( + href=f"{_HLJS_URL}/styles/default.min.css", + rel="stylesheet", + ), + ] + table = html.table( + html.thead( + html.tr( + html.th("Enabled Rulesets", class_="table-header"), + html.th("Desired Rulesets", class_="table-header"), + html.th("Diffs", class_="table-header"), + ), + ), + html.tbody(rulesets_rows(rulesets_data)), + class_="table table-auto w-11/12", + ) + scripts = [ + html.script(src=f"{_HLJS_URL}/highlight.min.js"), + html.script(html.SafeStr("hljs.highlightAll();")), + ] + _logger.debug("Building page.") + content = [table, *scripts] + return await _components.render_page([*styles], content) diff --git a/src/github_helper/_cli.py b/src/github_helper/_cli.py index 1834d22..9a1bcbb 100644 --- a/src/github_helper/_cli.py +++ b/src/github_helper/_cli.py @@ -180,12 +180,12 @@ def _get_cli_args(): required=True, ) - audit_repo = subparsers.add_parser( - "audit-repo", + audit_rulesets = subparsers.add_parser( + "audit-rulesets", description="", help="Audit repo rulesets against template.", ) - audit_repo.add_argument( + audit_rulesets.add_argument( "-r", "--repo", help="Name of repository required.", @@ -269,7 +269,7 @@ async def _run_cli_async(): # noqa: C901, PLR0912, PLR0915 complex case "audit-releases": data, sadness = await gh.audit_releases(repo) data = await adpt.transform_audit_releases_data(data) - case "audit-repo": + case "audit-rulesets": data, sadness = await gh.audit_rulesets(repo) data = await adpt.transform_audit_rulesets_data(data) case "audit-versions": diff --git a/src/github_helper/_utils/__init__.py b/src/github_helper/_utils/__init__.py index bef6096..5e2b38f 100644 --- a/src/github_helper/_utils/__init__.py +++ b/src/github_helper/_utils/__init__.py @@ -16,7 +16,7 @@ async def load_file(path): if not Path(path).is_file(): raise FileNotFoundError(f"{path} not exist") - async with aiofiles.open(path) as f: + async with aiofiles.open(path, encoding="utf-8") as f: file = await f.read() return file diff --git a/src/github_helper/api/__init__.py b/src/github_helper/api/__init__.py index 8b521be..81b2503 100644 --- a/src/github_helper/api/__init__.py +++ b/src/github_helper/api/__init__.py @@ -11,6 +11,7 @@ import logistro import orjson +from github_helper import cmp from github_helper._services import gh as srv from github_helper._services import repos as repo_srv from github_helper._services import ssh_srv @@ -349,7 +350,6 @@ async def fetch_json(name): url = f"https://{prefix}pypi.org/pypi/{name}/json" _logger.debug(url) try: - # TODO: probably need to check that project exists first jq_dir = ( r".releases // {} | " r"to_entries | map(" @@ -482,17 +482,19 @@ async def audit_rulesets(self, repo): "repo" and owner is assumed to be the current user. """ - rulesets_jq = jq.compile("map({(.name): .id}) | add") - config_path = _TEMPLATE_PATH / "audit-config.json" - config = await load_json(config_path) _ = await self.get_user() owner, repo = self._split_full_name(repo) repo_full_name = f"{owner}/{repo}" + + config_path = _TEMPLATE_PATH / "audit-config.json" + config = await load_json(config_path) + required_ruleset_templates = _audit.get_required_rulesets( config, repo_full_name, ) + rulesets_jq = jq.compile("map({(.name): .id}) | add") endpoint = f"repos/{owner}/{repo}/rulesets" _logger.debug(f"Calling API: {endpoint}") retval, out, err = await srv.gh_api(endpoint) @@ -512,30 +514,37 @@ async def audit_rulesets(self, repo): "_links", "target", ] - result = [ - {"template": t, "status": "missing ruleset"} - for t in required_ruleset_templates - if t not in active_rulesets - ] - for template, ruleset_id in active_rulesets.items(): - json_file = f"{template}.json" - if template not in required_ruleset_templates: - result.append({"template": template, "status": "additional ruleset"}) - continue - current_rulset = await self._get_ruleset(owner, repo, ruleset_id) - expected_ruleset = await _audit.load_template_ruleset(json_file) - _audit.remove_excluded_keys(current_rulset, excluded_keys) - _audit.remove_excluded_keys(expected_ruleset, excluded_keys) - - diffs = [] - diffs = await _audit.json_diff( - current_rulset, - expected_ruleset, - diffs, + added = active_rulesets.keys() - required_ruleset_templates + missing = required_ruleset_templates - active_rulesets.keys() + overlap = required_ruleset_templates & active_rulesets.keys() + + diffs = {} + for ruleset in overlap: + gh_id = active_rulesets[ruleset] + current = await self._get_ruleset(owner, repo, gh_id) + expected = await _audit.load_template_ruleset(f"{ruleset}.json") + _audit.remove_keys(current, excluded_keys) + _audit.remove_keys(expected, excluded_keys) + + diffs[ruleset] = await cmp.json_diff( + current, + expected, ) - for diff in diffs: - diff["template"] = template - result = result + diffs - sadness = len(result) - return result, sadness + + # this doesn't really work + ret = { + "enabled rulesets": ( + list(added) + len(missing) * [None] + list(diffs.keys()) + ), + "desired rulesets": ( + len(added) * [None] + list(missing) + list(diffs.keys()) + ), + "diffs": ( + len(added) * ["extra"] + + len(missing) * ["missing"] + + list(diffs.values()) + ), + } + # links? + return ret, 0 diff --git a/src/github_helper/api/_audit.py b/src/github_helper/api/_audit.py index 1392f10..2a7c936 100644 --- a/src/github_helper/api/_audit.py +++ b/src/github_helper/api/_audit.py @@ -1,8 +1,6 @@ import fnmatch from pathlib import Path -import jsondiff as jd - from github_helper._services.gh import GHError from github_helper._utils import load_json @@ -51,21 +49,9 @@ async def load_template_ruleset(path): return await load_json(path=template_path) -def remove_excluded_keys(ruleset, excluded_keys): - for key in excluded_keys: - if key in ruleset: - ruleset.pop(key) - - -async def json_diff(original, target, diffs): - json_diffs = jd.diff(original, target, marshal=True) - if not json_diffs: - return [] - else: - for diff_key in json_diffs: - if diff_key == "$delete": - for i in json_diffs[diff_key]: - diffs.append({"status": f"add'l. key: {i}"}) - else: - diffs.append({"status": diff_key}) - return diffs +def remove_keys(d, dotted_keys): + for dotted_key in dotted_keys: + keys = dotted_key.split(".") + for key in keys[:-1]: + d = d.get(key, {}) + d.pop(keys[-1], None) diff --git a/src/github_helper/cmp/__init__.py b/src/github_helper/cmp/__init__.py new file mode 100644 index 0000000..f30f7d1 --- /dev/null +++ b/src/github_helper/cmp/__init__.py @@ -0,0 +1,14 @@ +"""Functions for doing comparisons of objects.""" + +from typing import Any + + +async def json_diff(original: Any, target: Any, algo="jsondiff"): + """Conduct a tree diff using selected algorithm.""" + match algo: + case "jsondiff": + import jsondiff + + return jsondiff.diff(original, target, marshal=True) + case _: + raise NotImplementedError(f"{algo} is not implemented.") diff --git a/uv.lock b/uv.lock index 3e9226d..f4bb3dd 100644 --- a/uv.lock +++ b/uv.lock @@ -95,6 +95,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 }, ] +[[package]] +name = "asttokens" +version = "3.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 }, +] + [[package]] name = "async-lru" version = "2.0.5" @@ -131,6 +140,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/52/990d8b2a888be9fe1022eb89884849547e6341a8bec0072605fd70483db8/colored-2.3.0-py3-none-any.whl", hash = "sha256:2f7f455dc2ed490531b0b8e0ecf8f8513bb28a150d9f12f0e48b023641a55185", size = 18333 }, ] +[[package]] +name = "decorator" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 }, +] + [[package]] name = "execnet" version = "2.1.1" @@ -140,6 +158,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/43/09/2aea36ff60d16dd8879bdb2f5b3ee0ba8d08cbbdcdfe870e695ce3784385/execnet-2.1.1-py3-none-any.whl", hash = "sha256:26dee51f1b80cebd6d0ca8e74dd8745419761d3bef34163928cbebbdc4749fdc", size = 40612 }, ] +[[package]] +name = "executing" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 }, +] + [[package]] name = "frozenlist" version = "1.6.0" @@ -219,6 +246,7 @@ dependencies = [ [package.dev-dependencies] dev = [ + { name = "ipdb" }, { name = "mypy" }, { name = "poethepoet" }, { name = "pytest" }, @@ -244,6 +272,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ + { name = "ipdb", specifier = ">=0.13.13" }, { name = "mypy", specifier = ">=1.14.1" }, { name = "poethepoet", specifier = ">=0.30.0" }, { name = "pytest", specifier = ">=8.3.4" }, @@ -284,6 +313,64 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, ] +[[package]] +name = "ipdb" +version = "0.13.13" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "decorator" }, + { name = "ipython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/1b/7e07e7b752017f7693a0f4d41c13e5ca29ce8cbcfdcc1fd6c4ad8c0a27a0/ipdb-0.13.13.tar.gz", hash = "sha256:e3ac6018ef05126d442af680aad863006ec19d02290561ac88b8b1c0b0cfc726", size = 17042 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/4c/b075da0092003d9a55cf2ecc1cae9384a1ca4f650d51b00fc59875fe76f6/ipdb-0.13.13-py3-none-any.whl", hash = "sha256:45529994741c4ab6d2388bfa5d7b725c2cf7fe9deffabdb8a6113aa5ed449ed4", size = 12130 }, +] + +[[package]] +name = "ipython" +version = "9.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "decorator" }, + { name = "ipython-pygments-lexers" }, + { name = "jedi" }, + { name = "matplotlib-inline" }, + { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit" }, + { name = "pygments" }, + { name = "stack-data" }, + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/02/63a84444a7409b3c0acd1de9ffe524660e0e5d82ee473e78b45e5bfb64a4/ipython-9.2.0.tar.gz", hash = "sha256:62a9373dbc12f28f9feaf4700d052195bf89806279fc8ca11f3f54017d04751b", size = 4424394 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/ce/5e897ee51b7d26ab4e47e5105e7368d40ce6cfae2367acdf3165396d50be/ipython-9.2.0-py3-none-any.whl", hash = "sha256:fef5e33c4a1ae0759e0bba5917c9db4eb8c53fee917b6a526bd973e1ca5159f6", size = 604277 }, +] + +[[package]] +name = "ipython-pygments-lexers" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/33/1f075bf72b0b747cb3288d011319aaf64083cf2efef8354174e3ed4540e2/ipython_pygments_lexers-1.1.1-py3-none-any.whl", hash = "sha256:a9462224a505ade19a605f71f8fa63c2048833ce50abc86768a0d81d876dc81c", size = 8074 }, +] + +[[package]] +name = "jedi" +version = "0.19.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "parso" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 }, +] + [[package]] name = "jq" version = "1.8.0" @@ -342,6 +429,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/3f/afe76f8e2246ffbc867440cbcf90525264df0e658f8a5ca1f872b3f6192a/markdown-3.8-py3-none-any.whl", hash = "sha256:794a929b79c5af141ef5ab0f2f642d0f7b1872981250230e72682346f7cc90dc", size = 106210 }, ] +[[package]] +name = "matplotlib-inline" +version = "0.1.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "traitlets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 }, +] + [[package]] name = "multidict" version = "6.4.3" @@ -481,6 +580,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469 }, ] +[[package]] +name = "parso" +version = "0.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 }, +] + [[package]] name = "pastel" version = "0.2.1" @@ -490,6 +598,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/18/a8444036c6dd65ba3624c63b734d3ba95ba63ace513078e1580590075d21/pastel-0.2.1-py2.py3-none-any.whl", hash = "sha256:4349225fcdf6c2bb34d483e523475de5bb04a5c10ef711263452cb37d7dd4364", size = 5955 }, ] +[[package]] +name = "pexpect" +version = "4.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ptyprocess" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 }, +] + [[package]] name = "pluggy" version = "1.5.0" @@ -512,6 +632,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/d1/61431afe22577083fcb50614bc5e5aa73aa0ab35e3fc2ae49708a59ff70b/poethepoet-0.34.0-py3-none-any.whl", hash = "sha256:c472d6f0fdb341b48d346f4ccd49779840c15b30dfd6bc6347a80d6274b5e34e", size = 85851 }, ] +[[package]] +name = "prompt-toolkit" +version = "3.0.51" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wcwidth" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810 }, +] + [[package]] name = "propcache" version = "0.3.1" @@ -569,6 +701,33 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b8/d3/c3cb8f1d6ae3b37f83e1de806713a9b3642c5895f0215a62e1a4bd6e5e34/propcache-0.3.1-py3-none-any.whl", hash = "sha256:9a8ecf38de50a7f518c21568c80f985e776397b902f1ce0b01f799aba1608b40", size = 12376 }, ] +[[package]] +name = "ptyprocess" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 }, +] + +[[package]] +name = "pure-eval" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, +] + +[[package]] +name = "pygments" +version = "2.19.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 }, +] + [[package]] name = "pytest" version = "8.3.5" @@ -641,6 +800,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, ] +[[package]] +name = "stack-data" +version = "0.6.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asttokens" }, + { name = "executing" }, + { name = "pure-eval" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 }, +] + [[package]] name = "tabulate" version = "0.9.0" @@ -650,6 +823,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/40/44/4a5f08c96eb108af5cb50b41f76142f0afa346dfa99d5296fe7202a11854/tabulate-0.9.0-py3-none-any.whl", hash = "sha256:024ca478df22e9340661486f85298cff5f6dcdba14f3813e8830015b9ed1948f", size = 35252 }, ] +[[package]] +name = "traitlets" +version = "5.14.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 }, +] + [[package]] name = "types-aiofiles" version = "24.1.0.20250326" @@ -677,6 +859,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 }, ] +[[package]] +name = "wcwidth" +version = "0.2.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 }, +] + [[package]] name = "yarl" version = "1.20.0"