diff options
-rw-r--r-- | ganarchy/__init__.py | 527 | ||||
-rw-r--r-- | ganarchy/__main__.py | 3 | ||||
-rw-r--r-- | ganarchy/cli/__init__.py | 6 | ||||
-rw-r--r-- | ganarchy/cli/db.py | 71 | ||||
-rw-r--r-- | ganarchy/cli/run_targets.py | 111 | ||||
-rw-r--r-- | ganarchy/core.py | 165 | ||||
-rw-r--r-- | ganarchy/data.py | 7 | ||||
-rw-r--r-- | ganarchy/db.py | 336 | ||||
-rw-r--r-- | ganarchy/dirs.py | 53 | ||||
-rw-r--r-- | ganarchy/git.py | 131 | ||||
-rw-r--r-- | ganarchy/templating/__init__.py | 21 | ||||
-rw-r--r-- | ganarchy/templating/environment.py | 29 | ||||
-rw-r--r-- | ganarchy/templating/templates.py | 124 | ||||
-rw-r--r-- | ganarchy/templating/toml.py | 33 |
14 files changed, 1107 insertions, 510 deletions
diff --git a/ganarchy/__init__.py b/ganarchy/__init__.py index c7bc025..206bac9 100644 --- a/ganarchy/__init__.py +++ b/ganarchy/__init__.py @@ -1,5 +1,5 @@ # GAnarchy - decentralized project hub -# Copyright (C) 2019 Soni L. +# Copyright (C) 2019, 2020 Soni L. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -14,509 +14,24 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. -import abc -import builtins -import hashlib -import hmac -import os -import re -import sqlite3 -import subprocess - -import abdl -import click -import jinja2 -import qtoml -import requests - -from collections import defaultdict -from urllib.parse import urlparse - -import ganarchy as m_ganarchy - -MIGRATIONS = { - "toml-config": ( - ( - '''UPDATE "repo_history" SET "project" = (SELECT "git_commit" FROM "config") WHERE "project" IS NULL''', - '''ALTER TABLE "repos" RENAME TO "repos_old"''',), - ( - '''UPDATE "repo_history" SET "project" = NULL WHERE "project" = (SELECT "git_commit" FROM "config")''', - '''ALTER TABLE "repos_old" RENAME TO "repos"''',), - "switches to toml config format. the old 'repos' table is preserved as 'repos_old'" - ), - "better-project-management": ( - ( - '''ALTER TABLE "repos" ADD COLUMN "branch" TEXT''', - '''ALTER TABLE "repos" ADD COLUMN "project" TEXT''', - '''CREATE UNIQUE INDEX "repos_url_branch_project" ON "repos" ("url", "branch", "project")''', - '''CREATE INDEX "repos_project" ON "repos" ("project")''', - '''ALTER TABLE "repo_history" ADD COLUMN "branch" TEXT''', - '''ALTER TABLE "repo_history" ADD COLUMN "project" TEXT''', - '''CREATE INDEX "repo_history_url_branch_project" ON "repo_history" ("url", "branch", "project")''',), - ( - '''DELETE FROM "repos" WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''', - '''DELETE FROM "repo_history" WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''',), - "supports multiple projects, and allows choosing non-default branches" - ), - "test": ( - ('''-- apply''',), - ('''-- revert''',), - "does nothing" - ) - } - -data_home = os.environ.get('XDG_DATA_HOME', '') -if not data_home: - data_home = os.environ['HOME'] + '/.local/share' -data_home = data_home + "/ganarchy" - -cache_home = os.environ.get('XDG_CACHE_HOME', '') -if not cache_home: - cache_home = os.environ['HOME'] + '/.cache' -cache_home = cache_home + "/ganarchy" - -config_home = os.environ.get('XDG_CONFIG_HOME', '') -if not config_home: - config_home = os.environ['HOME'] + '/.config' -config_home = config_home + "/ganarchy" - -config_dirs = os.environ.get('XDG_CONFIG_DIRS', '') -if not config_dirs: - config_dirs = '/etc/xdg' -# TODO check if this is correct -config_dirs = [config_dir + "/ganarchy" for config_dir in config_dirs.split(':')] - -def get_template_loader(): - from jinja2 import DictLoader, FileSystemLoader, ChoiceLoader - return ChoiceLoader([ - FileSystemLoader([config_home + "/templates"] + [config_dir + "/templates" for config_dir in config_dirs]), - DictLoader({ - ## index.html - 'index.html': """<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <!-- - GAnarchy - project homepage generator - Copyright (C) 2019 Soni L. - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see <https://www.gnu.org/licenses/>. - --> - <title>{{ ganarchy.title|e }}</title> - <meta name="description" content="{{ ganarchy.title|e }}" /> - <!--if your browser doesn't like the following, use a different browser.--> - <script type="application/javascript" src="/index.js"></script> - </head> - <body> - <h1>{{ ganarchy.title|e }}</h1> - <p>This is {{ ganarchy.title|e }}. Currently tracking the following projects:</p> - <ul> - {% for project in ganarchy.projects -%} - <li><a href="/project/{{ project.commit|e }}">{{ project.title|e }}</a>: {{ project.description|e }}</li> - {% endfor -%} - </ul> - <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p> - <p> - <a href="{{ ganarchy.base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>. - </p> - </body> -</html> -""", - ## index.toml - 'index.toml': """# Generated by GAnarchy - -{%- for project, repos in config.projects.items() %} -[projects.{{project}}] -{%- for repo_url, branches in repos.items() %}{% for branch, options in branches.items() %}{% if options.active %} -"{{repo_url|tomle}}".{% if branch %}"{{branch|tomle}}"{% else %}HEAD{% endif %} = { active=true } -{%- endif %}{% endfor %} -{%- endfor %} -{% endfor -%} -""", - ## project.html FIXME - 'project.html': """<!DOCTYPE html> -<html lang="en"> - <head> - <meta charset="utf-8" /> - <!-- - GAnarchy - project homepage generator - Copyright (C) 2019 Soni L. - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see <https://www.gnu.org/licenses/>. - --> - <title>{{ project_title|e }}</title> - {% if project_desc %}<meta name="description" content="{{ project_desc|e }}" />{% endif %} - <style type="text/css">.branchname { color: #808080; font-style: italic; }</style> - </head> - <body> - <h1>{{ project_title|e }}</h1> - <p>Tracking <span id="project_commit"><a href="web+ganarchy:{{ project_commit }}">{{ project_commit }}</a></span></p> - <div id="project_body"><p>{{ project_body|e|replace("\n\n", "</p><p>") }}</p></div> - <ul> - {% for url, msg, img, branch in repos -%} - <li><a href="{{ url|e }}">{{ url|e }}</a>{% if branch %} <span class="branchname">[{{ branch|e }}]</span>{% endif %}: {{ msg|e }}</li> - {% endfor -%} - </ul> - <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p> - <p> - <a href="/">Main page</a>. - <a href="{{ base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>. - </p> - </body> -</html> -""", - ## history.svg FIXME - 'history.svg': """""", - }) - ]) - -tomletrans = str.maketrans({ - 0: '\\u0000', 1: '\\u0001', 2: '\\u0002', 3: '\\u0003', 4: '\\u0004', - 5: '\\u0005', 6: '\\u0006', 7: '\\u0007', 8: '\\b', 9: '\\t', 10: '\\n', - 11: '\\u000B', 12: '\\f', 13: '\\r', 14: '\\u000E', 15: '\\u000F', - 16: '\\u0010', 17: '\\u0011', 18: '\\u0012', 19: '\\u0013', 20: '\\u0014', - 21: '\\u0015', 22: '\\u0016', 23: '\\u0017', 24: '\\u0018', 25: '\\u0019', - 26: '\\u001A', 27: '\\u001B', 28: '\\u001C', 29: '\\u001D', 30: '\\u001E', - 31: '\\u001F', '"': '\\"', '\\': '\\\\' - }) -def tomlescape(value): - return value.translate(tomletrans) - -def get_env(): - env = jinja2.Environment(loader=get_template_loader(), autoescape=False) - env.filters['tomlescape'] = tomlescape - env.filters['tomle'] = env.filters['tomlescape'] - return env - -# FIXME -from ganarchy.cli import main as ganarchy - -@ganarchy.command() -def initdb(): - """Initializes the ganarchy database.""" - os.makedirs(data_home, exist_ok=True) - conn = sqlite3.connect(data_home + "/ganarchy.db") - c = conn.cursor() - c.execute('''CREATE TABLE "repo_history" ("entry" INTEGER PRIMARY KEY ASC AUTOINCREMENT, "url" TEXT, "count" INTEGER, "head_commit" TEXT, "branch" TEXT, "project" TEXT)''') - c.execute('''CREATE INDEX "repo_history_url_branch_project" ON "repo_history" ("url", "branch", "project")''') - conn.commit() - conn.close() - -def migrations(): - @ganarchy.group() - def migrations(): - """Modifies the DB to work with a newer/older version. - - WARNING: THIS COMMAND CAN BE EXTREMELY DESTRUCTIVE!""" - - @migrations.command() - @click.argument('migration') - def apply(migration): - """Applies the migration with the given name.""" - conn = sqlite3.connect(data_home + "/ganarchy.db") - c = conn.cursor() - click.echo(MIGRATIONS[migration][0]) - for migration in MIGRATIONS[migration][0]: - c.execute(migration) - conn.commit() - conn.close() - - @click.argument('migration') - @migrations.command() - def revert(migration): - """Reverts the migration with the given name.""" - conn = sqlite3.connect(data_home + "/ganarchy.db") - c = conn.cursor() - click.echo(MIGRATIONS[migration][1]) - for migration in MIGRATIONS[migration][1]: - c.execute(migration) - conn.commit() - conn.close() - - @click.argument('migration', required=False) - @migrations.command() - def info(migration): - """Shows information about the migration with the given name.""" - if not migration: - # TODO could be improved - click.echo(MIGRATIONS.keys()) - else: - click.echo(MIGRATIONS[migration][2]) - -migrations() - -class GitError(LookupError): - """Raised when a git operation fails, generally due to a missing commit or branch, or network connection issues.""" - pass - -class Git: - def __init__(self, path): - self.path = path - self.base = ("git", "-C", path) - - def get_hash(self, target): - try: - return subprocess.check_output(self.base + ("show", target, "-s", "--format=format:%H", "--"), stderr=subprocess.DEVNULL).decode("utf-8") - except subprocess.CalledProcessError as e: - raise GitError from e - - def get_commit_message(self, target): - try: - return subprocess.check_output(self.base + ("show", target, "-s", "--format=format:%B", "--"), stderr=subprocess.DEVNULL).decode("utf-8", "replace") - except subprocess.CalledProcessError as e: - raise GitError from e - -# Currently we only use one git repo, at cache_home -GIT = Git(cache_home) - -class Repo: - def __init__(self, dbconn, project_commit, url, branch, head_commit, list_metadata=False): - self.url = url - self.branch = branch - self.project_commit = project_commit - self.erroring = False - - if not branch: - self.branchname = "gan" + hashlib.sha256(url.encode("utf-8")).hexdigest() - self.head = "HEAD" - else: - self.branchname = "gan" + hmac.new(branch.encode("utf-8"), url.encode("utf-8"), "sha256").hexdigest() - self.head = "refs/heads/" + branch - - if head_commit: - self.hash = head_commit - else: - try: # FIXME should we even do this? - self.hash = GIT.get_hash(self.branchname) - except GitError: - self.erroring = True - self.hash = None - - self.message = None - if list_metadata: - try: - self.update_metadata() - except GitError: - self.erroring = True - pass - - def update_metadata(self): - self.message = GIT.get_commit_message(self.branchname) - - def update(self, updating=True): - """ - Updates the git repo, returning new metadata. - """ - if updating: - try: - subprocess.check_output(["git", "-C", cache_home, "fetch", "-q", self.url, "+" + self.head + ":" + self.branchname], stderr=subprocess.STDOUT) - except subprocess.CalledProcessError as e: - # This may error for various reasons, but some are important: dead links, etc - click.echo(e.output, err=True) - self.erroring = True - return None - pre_hash = self.hash - try: - post_hash = GIT.get_hash(self.branchname) - except GitError as e: - # This should never happen, but maybe there's some edge cases? - # TODO check - self.erroring = True - return None - self.hash = post_hash - if not pre_hash: - pre_hash = post_hash - try: - count = int(subprocess.check_output(["git", "-C", cache_home, "rev-list", "--count", pre_hash + ".." + post_hash, "--"]).decode("utf-8").strip()) - except subprocess.CalledProcessError: - count = 0 # force-pushed - try: - if updating: - subprocess.check_call(["git", "-C", cache_home, "merge-base", "--is-ancestor", self.project_commit, self.branchname], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) - self.update_metadata() - return count - except (subprocess.CalledProcessError, GitError) as e: - click.echo(e, err=True) - self.erroring = True - return None - -class Project: - def __init__(self, dbconn, project_commit, list_repos=False): - self.commit = project_commit - self.refresh_metadata() - self.repos = None - if list_repos: - self.list_repos(dbconn) - - def list_repos(self, dbconn): - repos = [] - with dbconn: - for (e, url, branch, head_commit) in dbconn.execute('''SELECT "max"("e"), "url", "branch", "head_commit" FROM (SELECT "max"("T1"."entry") "e", "T1"."url", "T1"."branch", "T1"."head_commit" FROM "repo_history" "T1" - WHERE (SELECT "active" FROM "repos" "T2" WHERE "url" = "T1"."url" AND "branch" IS "T1"."branch" AND "project" IS ?1) - GROUP BY "T1"."url", "T1"."branch" - UNION - SELECT null, "T3"."url", "T3"."branch", null FROM "repos" "T3" WHERE "active" AND "project" IS ?1) - GROUP BY "url" ORDER BY "e"''', (self.commit,)): - repos.append(Repo(dbconn, self.commit, url, branch, head_commit)) - self.repos = repos - - def refresh_metadata(self): - try: - project = GIT.get_commit_message(self.commit) - project_title, project_desc = (lambda x: x.groups() if x is not None else ('', None))(re.fullmatch('^\\[Project\\]\s+(.+?)(?:\n\n(.+))?$', project, flags=re.ASCII|re.DOTALL|re.IGNORECASE)) - if not project_title.strip(): # FIXME - project_title, project_desc = ("Error parsing project commit",)*2 - # if project_desc: # FIXME - # project_desc = project_desc.strip() - self.commit_body = project - self.title = project_title - self.description = project_desc - except GitError: - self.commit_body = None - self.title = None - self.description = None - - def update(self, updating=True): - # TODO? check if working correctly - results = [(repo, repo.update(updating)) for repo in self.repos] - self.refresh_metadata() - return results - -class GAnarchy: - def __init__(self, dbconn, config, list_projects=False, list_repos=False): - base_url = config.base_url - title = config.title - if not base_url: - # FIXME use a more appropriate error type - raise ValueError - if not title: - title = "GAnarchy on " + urlparse(base_url).hostname - self.title = title - self.base_url = base_url - # load config onto DB - c = dbconn.cursor() - c.execute('''CREATE TEMPORARY TABLE "repos" ("url" TEXT PRIMARY KEY, "active" INT, "branch" TEXT, "project" TEXT)''') - c.execute('''CREATE UNIQUE INDEX "temp"."repos_url_branch_project" ON "repos" ("url", "branch", "project")''') - c.execute('''CREATE INDEX "temp"."repos_project" ON "repos" ("project")''') - c.execute('''CREATE INDEX "temp"."repos_active" ON "repos" ("active")''') - for (project_commit, repos) in config.projects.items(): - for (repo_url, branches) in repos.items(): - for (branchname, options) in branches.items(): - if options['active']: # no need to insert inactive repos since they get ignored anyway - c.execute('''INSERT INTO "repos" VALUES (?, ?, ?, ?)''', (repo_url, 1, branchname, project_commit)) - dbconn.commit() - if list_projects: - projects = [] - with dbconn: - for (project,) in dbconn.execute('''SELECT DISTINCT "project" FROM "repos" '''): - projects.append(Project(dbconn, project, list_repos=list_repos)) - projects.sort(key=lambda project: project.title) # sort projects by title - self.projects = projects - else: - self.projects = None - -@ganarchy.command() -@click.argument('out', required=True) -def run(out): - """Runs ganarchy standalone. - - This will run ganarchy so it regularly updates the output directory given by OUT. - Additionally, it'll also search for the following hooks in its config dirs: - - - post_object_update_hook - executed after an object is updated. - - - post_update_cycle_hook - executed after all objects in an update cycle are updated.""" - pass +#import abc +#import builtins +#import hashlib +#import hmac +#import os +#import re +#import sqlite3 +#import subprocess +# +#import click +#import jinja2 +#import qtoml +#import requests +# +#from collections import defaultdict +#from urllib.parse import urlparse +# +#import ganarchy as m_ganarchy +#import ganarchy.dirs +#import ganarchy.git -@ganarchy.command() -@click.option('--update/--no-update', default=True) -@click.argument('project', required=False) -def cron_target(update, project): - """Runs ganarchy as a cron target.""" - conf = None - # reverse order is intentional - for d in reversed(config_dirs): - try: - conf = Config(open(d + "/config.toml", 'r', encoding='utf-8', newline=''), conf) - except (OSError, UnicodeDecodeError, qtoml.decoder.TOMLDecodeError): - pass - with open(config_home + "/config.toml", 'r', encoding='utf-8', newline='') as f: - conf = Config(f, conf) - env = get_env() - if project == "config": - # render the config - # doesn't have access to a GAnarchy object. this is deliberate. - template = env.get_template('index.toml') - click.echo(template.render(config = conf)) - return - if project == "project-list": - # could be done with a template but eh w/e, this is probably better - for project in conf.projects.keys(): - click.echo(project) - return - # make sure the cache dir exists - os.makedirs(cache_home, exist_ok=True) - # make sure it is a git repo - subprocess.call(["git", "-C", cache_home, "init", "-q"]) - conn = sqlite3.connect(data_home + "/ganarchy.db") - instance = GAnarchy(conn, conf, list_projects=project in ["index", "config"]) - if project == "index": - # render the index - template = env.get_template('index.html') - click.echo(template.render(ganarchy = instance)) - return - if not instance.base_url or not project: - click.echo("No base URL or project commit specified", err=True) - return - entries = [] - generate_html = [] - c = conn.cursor() - p = Project(conn, project, list_repos=True) - results = p.update(update) - for (repo, count) in results: - if count is not None: - entries.append((repo.url, count, repo.hash, repo.branch, project)) - generate_html.append((repo.url, repo.message, count, repo.branch)) - # sort stuff twice because reasons - entries.sort(key=lambda x: x[1], reverse=True) - generate_html.sort(key=lambda x: x[2], reverse=True) - if update: - c.executemany('''INSERT INTO "repo_history" ("url", "count", "head_commit", "branch", "project") VALUES (?, ?, ?, ?, ?)''', entries) - conn.commit() - html_entries = [] - for (url, msg, count, branch) in generate_html: - history = c.execute('''SELECT "count" FROM "repo_history" WHERE "url" = ? AND "branch" IS ? AND "project" IS ? ORDER BY "entry" ASC''', (url, branch, project)).fetchall() - # TODO process history into SVG - html_entries.append((url, msg, "", branch)) - template = env.get_template('project.html') - click.echo(template.render(project_title = p.title, - project_desc = p.description, - project_body = p.commit_body, - project_commit = p.commit, - repos = html_entries, - base_url = instance.base_url, - # I don't think this thing supports deprecating the above? - project = p, - ganarchy = instance)) diff --git a/ganarchy/__main__.py b/ganarchy/__main__.py index c7795ed..1bab5d1 100644 --- a/ganarchy/__main__.py +++ b/ganarchy/__main__.py @@ -1,5 +1,5 @@ # GAnarchy - decentralized project hub -# Copyright (C) 2019 Soni L. +# Copyright (C) 2019, 2020 Soni L. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -21,6 +21,7 @@ import ganarchy.cli import ganarchy # Additional CLI commands +import ganarchy.cli.db import ganarchy.cli.debug import ganarchy.cli.merge_configs diff --git a/ganarchy/cli/__init__.py b/ganarchy/cli/__init__.py index 9effabb..727c48d 100644 --- a/ganarchy/cli/__init__.py +++ b/ganarchy/cli/__init__.py @@ -14,6 +14,12 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. +"""The GAnarchy CLI. + +This module just defines the main command group. Submodules define +actual commands. +""" + import click @click.group() diff --git a/ganarchy/cli/db.py b/ganarchy/cli/db.py new file mode 100644 index 0000000..e754c34 --- /dev/null +++ b/ganarchy/cli/db.py @@ -0,0 +1,71 @@ +# This file is part of GAnarchy - decentralized project hub +# Copyright (C) 2020 Soni L. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +"""Database-related CLI commands. + +""" + +import os + +import click + +import ganarchy.cli +import ganarchy.data +import ganarchy.db +import ganarchy.dirs + +@ganarchy.cli.main.command() +def initdb(): + """Initializes the ganarchy database.""" + # TODO: makedirs in a separate command? + os.makedirs(ganarchy.dirs.DATA_HOME, exist_ok=True) + db = ganarchy.db.connect_database(ganarchy.data.ConfigManager.new_default()) + db.initialize() + db.close() + +@ganarchy.cli.main.group() +def migrations(): + """Modifies the DB to work with a newer/older version. + + WARNING: THIS COMMAND CAN BE EXTREMELY DESTRUCTIVE!""" + +@migrations.command() +@click.argument('migration') +def apply(migration): + """Applies the migration with the given name.""" + db = ganarchy.db.connect_database(ganarchy.data.ConfigManager.new_default()) + click.echo(ganarchy.db.MIGRATIONS[migration][0]) + db.apply_migration(migration) + db.close() + +@click.argument('migration') +@migrations.command() +def revert(migration): + """Reverts the migration with the given name.""" + db = ganarchy.db.connect_database(ganarchy.data.ConfigManager.new_default()) + click.echo(ganarchy.db.MIGRATIONS[migration][1]) + db.revert_migration(migration) + db.close() + +@click.argument('migration', required=False) +@migrations.command() +def info(migration): + """Shows information about the migration with the given name.""" + if not migration: + # TODO could be improved + click.echo(ganarchy.db.MIGRATIONS.keys()) + else: + click.echo(ganarchy.db.MIGRATIONS[migration][2]) diff --git a/ganarchy/cli/run_targets.py b/ganarchy/cli/run_targets.py new file mode 100644 index 0000000..5bf48d9 --- /dev/null +++ b/ganarchy/cli/run_targets.py @@ -0,0 +1,111 @@ +# This file is part of GAnarchy - decentralized project hub +# Copyright (C) 2020 Soni L. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +"""This module contains the CLI Run Targets. +""" + +import click + +from ganarchy import cli +from ganarchy.templating import environment + +#@cli.main.command() +#@click.argument('out', required=True) +#def run(out): +# """Runs ganarchy standalone. +# +# This will run ganarchy so it regularly updates the output directory given by OUT. +# Additionally, it'll also search for the following hooks in its config dirs: +# +# - post_object_update_hook - executed after an object is updated. +# +# - post_update_cycle_hook - executed after all objects in an update cycle are updated.""" +# pass + +@ganarchy.command() +@click.option('--update/--no-update', default=True) +@click.argument('project', required=False) +def cron_target(update, project): + """Runs ganarchy as a cron target. + + "Deprecated". Useful if you want full control over how GAnarchy + generates the pages. + """ + #conf = None + ## reverse order is intentional + #for d in reversed(config_dirs): + # try: + # conf = Config(open(d + "/config.toml", 'r', encoding='utf-8', newline=''), conf) + # except (OSError, UnicodeDecodeError, qtoml.decoder.TOMLDecodeError): + # pass + #with open(config_home + "/config.toml", 'r', encoding='utf-8', newline='') as f: + # conf = Config(f, conf) + env = get_env() + if project == "config": + # render the config + # doesn't have access to a GAnarchy object. this is deliberate. + template = env.get_template('index.toml') + click.echo(template.render(config = conf)) + return + if project == "project-list": + # could be done with a template but eh w/e, this is probably better + for project in conf.projects.keys(): + click.echo(project) + return + # make sure the cache dir exists + os.makedirs(cache_home, exist_ok=True) + # make sure it is a git repo + subprocess.call(["git", "-C", cache_home, "init", "-q"]) + conn = sqlite3.connect(data_home + "/ganarchy.db") + instance = GAnarchy(conn, conf, list_projects=project in ["index", "config"]) + if project == "index": + # render the index + template = env.get_template('index.html') + click.echo(template.render(ganarchy = instance)) + return + if not instance.base_url or not project: + click.echo("No base URL or project commit specified", err=True) + return + entries = [] + generate_html = [] + c = conn.cursor() + p = Project(conn, project, list_repos=True) + results = p.update(update) + for (repo, count) in results: + if count is not None: + entries.append((repo.url, count, repo.hash, repo.branch, project)) + generate_html.append((repo.url, repo.message, count, repo.branch)) + # sort stuff twice because reasons + entries.sort(key=lambda x: x[1], reverse=True) + generate_html.sort(key=lambda x: x[2], reverse=True) + if update: + c.executemany('''INSERT INTO "repo_history" ("url", "count", "head_commit", "branch", "project") VALUES (?, ?, ?, ?, ?)''', entries) + conn.commit() + html_entries = [] + for (url, msg, count, branch) in generate_html: + history = c.execute('''SELECT "count" FROM "repo_history" WHERE "url" = ? AND "branch" IS ? AND "project" IS ? ORDER BY "entry" ASC''', (url, branch, project)).fetchall() + # TODO process history into SVG + html_entries.append((url, msg, "", branch)) + template = env.get_template('project.html') + click.echo(template.render(project_title = p.title, + project_desc = p.description, + project_body = p.commit_body, + project_commit = p.commit, + repos = html_entries, + base_url = instance.base_url, + # I don't think this thing supports deprecating the above? + project = p, + ganarchy = instance)) diff --git a/ganarchy/core.py b/ganarchy/core.py new file mode 100644 index 0000000..cf5edd4 --- /dev/null +++ b/ganarchy/core.py @@ -0,0 +1,165 @@ +# This file is part of GAnarchy - decentralized project hub +# Copyright (C) 2020 Soni L. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +import ganarchy.git +import ganarchy.dirs + +# Currently we only use one git repo, at CACHE_HOME +# TODO optimize +GIT = ganarchy.git.Git(ganarchy.dirs.CACHE_HOME) + +class Repo: + def __init__(self, dbconn, project_commit, url, branch, head_commit, list_metadata=False): + self.url = url + self.branch = branch + self.project_commit = project_commit + self.erroring = False + + if not branch: + self.branchname = "gan" + hashlib.sha256(url.encode("utf-8")).hexdigest() + self.head = "HEAD" + else: + self.branchname = "gan" + hmac.new(branch.encode("utf-8"), url.encode("utf-8"), "sha256").hexdigest() + self.head = "refs/heads/" + branch + + if head_commit: + self.hash = head_commit + else: + try: # FIXME should we even do this? + self.hash = GIT.get_hash(self.branchname) + except ganarchy.git.GitError: + self.erroring = True + self.hash = None + + self.message = None + if list_metadata: + try: + self.update_metadata() + except ganarchy.git.GitError: + self.erroring = True + pass + + def update_metadata(self): + self.message = GIT.get_commit_message(self.branchname) + + def update(self, updating=True): + """Updates the git repo, returning new metadata. + """ + if updating: + try: + GIT.force_fetch(self.url, self.head, self.branchname) + except ganarchy.git.GitError as e: + # This may error for various reasons, but some + # are important: dead links, etc + click.echo(e.output, err=True) + self.erroring = True + return None + pre_hash = self.hash + try: + post_hash = GIT.get_hash(self.branchname) + except ganarchy.git.GitError as e: + # This should never happen, but maybe there's some edge cases? + # TODO check + self.erroring = True + return None + self.hash = post_hash + if not pre_hash: + pre_hash = post_hash + count = GIT.get_count(pre_hash, post_hash) + try: + if updating: + GIT.check_history(self.branchname, self.project_commit) + self.update_metadata() + return count + except ganarchy.git.GitError as e: + click.echo(e, err=True) + self.erroring = True + return None + +class Project: + def __init__(self, dbconn, project_commit, list_repos=False): + self.commit = project_commit + self.refresh_metadata() + self.repos = None + if list_repos: + self.list_repos(dbconn) + + def list_repos(self, dbconn): + repos = [] + with dbconn: + for (e, url, branch, head_commit) in dbconn.execute('''SELECT "max"("e"), "url", "branch", "head_commit" FROM (SELECT "max"("T1"."entry") "e", "T1"."url", "T1"."branch", "T1"."head_commit" FROM "repo_history" "T1" + WHERE (SELECT "active" FROM "repos" "T2" WHERE "url" = "T1"."url" AND "branch" IS "T1"."branch" AND "project" IS ?1) + GROUP BY "T1"."url", "T1"."branch" + UNION + SELECT null, "T3"."url", "T3"."branch", null FROM "repos" "T3" WHERE "active" AND "project" IS ?1) + GROUP BY "url" ORDER BY "e"''', (self.commit,)): + repos.append(Repo(dbconn, self.commit, url, branch, head_commit)) + self.repos = repos + + def refresh_metadata(self): + try: + project = GIT.get_commit_message(self.commit) + project_title, project_desc = (lambda x: x.groups() if x is not None else ('', None))(re.fullmatch('^\\[Project\\]\s+(.+?)(?:\n\n(.+))?$', project, flags=re.ASCII|re.DOTALL|re.IGNORECASE)) + if not project_title.strip(): # FIXME + project_title, project_desc = ("Error parsing project commit",)*2 + # if project_desc: # FIXME + # project_desc = project_desc.strip() + self.commit_body = project + self.title = project_title + self.description = project_desc + except ganarchy.git.GitError: + self.commit_body = None + self.title = None + self.description = None + + def update(self, updating=True): + # TODO? check if working correctly + results = [(repo, repo.update(updating)) for repo in self.repos] + self.refresh_metadata() + return results + +class GAnarchy: + def __init__(self, dbconn, config, list_projects=False, list_repos=False): + base_url = config.base_url + title = config.title + if not base_url: + # FIXME use a more appropriate error type + raise ValueError + if not title: + title = "GAnarchy on " + urlparse(base_url).hostname + self.title = title + self.base_url = base_url + # load config onto DB + c = dbconn.cursor() + c.execute('''CREATE TEMPORARY TABLE "repos" ("url" TEXT PRIMARY KEY, "active" INT, "branch" TEXT, "project" TEXT)''') + c.execute('''CREATE UNIQUE INDEX "temp"."repos_url_branch_project" ON "repos" ("url", "branch", "project")''') + c.execute('''CREATE INDEX "temp"."repos_project" ON "repos" ("project")''') + c.execute('''CREATE INDEX "temp"."repos_active" ON "repos" ("active")''') + for (project_commit, repos) in config.projects.items(): + for (repo_url, branches) in repos.items(): + for (branchname, options) in branches.items(): + if options['active']: # no need to insert inactive repos since they get ignored anyway + c.execute('''INSERT INTO "repos" VALUES (?, ?, ?, ?)''', (repo_url, 1, branchname, project_commit)) + dbconn.commit() + if list_projects: + projects = [] + with dbconn: + for (project,) in dbconn.execute('''SELECT DISTINCT "project" FROM "repos" '''): + projects.append(Project(dbconn, project, list_repos=list_repos)) + projects.sort(key=lambda project: project.title) # sort projects by title + self.projects = projects + else: + self.projects = None diff --git a/ganarchy/data.py b/ganarchy/data.py index 7de0764..730a5a9 100644 --- a/ganarchy/data.py +++ b/ganarchy/data.py @@ -1,5 +1,5 @@ # This file is part of GAnarchy - decentralized project hub -# Copyright (C) 2019 Soni L. +# Copyright (C) 2019, 2020 Soni L. # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by @@ -35,6 +35,8 @@ import requests from enum import Enum from urllib.parse import urlparse +import ganarchy.dirs + # TODO move elsewhere class URIPredicate(abdl.predicates.Predicate): def __init__(self, ports=range(1,65536), schemes=('https',)): @@ -391,8 +393,7 @@ class ConfigManager(DataSource): @classmethod def new_default(cls): - from ganarchy import config_home, config_dirs - srcs = [LocalDataSource(d + "/config.toml") for d in [config_home] + config_dirs] + srcs = [LocalDataSource(d + "/config.toml") for d in [ganarchy.dirs.CONFIG_HOME] + ganarchy.dirs.CONFIG_DIRS] return cls(srcs + [DefaultsDataSource()]) def exists(self): diff --git a/ganarchy/db.py b/ganarchy/db.py new file mode 100644 index 0000000..f16081a --- /dev/null +++ b/ganarchy/db.py @@ -0,0 +1,336 @@ +# This file is part of GAnarchy - decentralized project hub +# Copyright (C) 2020 Soni L. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +"""This module handles GAnarchy's database. + +Attributes: + MIGRATIONS: Migrations. +""" + +import sqlite3 + +import ganarchy.dirs +import ganarchy.data + +# FIXME this should not be used directly because it's a pain. +MIGRATIONS = { + "toml-config": ( + ( + '''UPDATE "repo_history" + SET "project" = (SELECT "git_commit" FROM "config") + WHERE "project" IS NULL''', + + '''ALTER TABLE "repos" + RENAME TO "repos_old"''', + ), + ( + '''UPDATE "repo_history" + SET "project" = NULL + WHERE "project" = (SELECT "git_commit" FROM "config")''', + + '''ALTER TABLE "repos_old" + RENAME TO "repos"''', + ), + "switches to toml config format. the old 'repos' " #cont + "table is preserved as 'repos_old'" + ), + "better-project-management": ( + ( + '''ALTER TABLE "repos" + ADD COLUMN "branch" TEXT''', + + '''ALTER TABLE "repos" + ADD COLUMN "project" TEXT''', + + '''CREATE UNIQUE INDEX "repos_url_branch_project" + ON "repos" ("url", "branch", "project")''', + + '''CREATE INDEX "repos_project" + ON "repos" ("project")''', + + '''ALTER TABLE "repo_history" + ADD COLUMN "branch" TEXT''', + + '''ALTER TABLE "repo_history" + ADD COLUMN "project" TEXT''', + + '''CREATE INDEX "repo_history_url_branch_project" + ON "repo_history" ("url", "branch", "project")''', + ), + ( + '''DELETE FROM "repos" + WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''', + '''DELETE FROM "repo_history" + WHERE "branch" IS NOT NULL OR "project" IS NOT NULL''', + ), + "supports multiple projects, and allows choosing " #cont + "non-default branches" + ), + "test": ( + ( + '''-- apply''', + ), + ( + '''-- revert''', + ), + "does nothing" + ) + } + +class Database: + """A database connection/session, returned by ``connect_database``. + + Some methods may require repos to be loaded. + """ + + def __init__(self, conn): + self.conn = conn + + def initialize(self): + """Initializes the database tables as expected by GAnarchy. + """ + c = self.conn.cursor() + c.execute(''' + CREATE TABLE "repo_history" ( + "entry" INTEGER PRIMARY KEY ASC AUTOINCREMENT, + "url" TEXT, + "count" INTEGER, + "head_commit" TEXT, + "branch" TEXT, + "project" TEXT + ) + ''') + c.execute(''' + CREATE INDEX "repo_history_url_branch_project" + ON "repo_history" ("url", "branch", "project") + ''') + self.conn.commit() + c.close() + + def apply_migration(self, migration): + """Applies a migration, by name. + + WARNING: Destructive operation. + + Args: + migration (str): The name of the migration. + """ + c = self.conn.cursor() + for migration in MIGRATIONS[migration][0]: + c.execute(migration) + self.conn.commit() + c.close() + + def revert_migration(self, migration): + """Reverts a previously-applied migration, by name. + + WARNING: Destructive operation. + + Args: + migration (str): The name of the migration. + """ + c = self.conn.cursor() + for migration in MIGRATIONS[migration][1]: + c.execute(migration) + self.conn.commit() + c.close() + + def load_repos(self, effective_repo_list): + """Loads repos from repo list. + + Must be done once for each instance of Database. + + Args: + effective_repo_list (ganarchy.data.DataSource): The data + source for the repo list. + """ + c = self.conn.cursor() + c.execute(''' + CREATE TEMPORARY TABLE "repos" ( + "url" TEXT PRIMARY KEY, + "active" INT, + "branch" TEXT, + "project" TEXT + ) + ''') + c.execute(''' + CREATE UNIQUE INDEX "temp"."repos_url_branch_project" + ON "repos" ("url", "branch", "project") + ''') + c.execute(''' + CREATE INDEX "temp"."repos_project" + ON "repos" ("project") + ''') + c.execute(''' + CREATE INDEX "temp"."repos_active" + ON "repos" ("active") + ''') + for repo in effective_repo_list.get_property_values( + ganarchy.data.DataProperty.VCS_REPOS + ): + if repo.active: + c.execute( + '''INSERT INTO "repos" VALUES (?, ?, ?, ?)''', + (repo.uri, 1, repo.branch, repo.project_commit) + ) + self.conn.commit() + c.close() + + def insert_activity(self, project_commit, uri, branch, head, count): + """Inserts activity of a repo-branch. + + Args: + project_commit: The project commit. + uri: The repo uri. + branch: The branch. + head: The latest known head commit. + count: The number of new commits. + """ + self.insert_activities([(project_commit, uri, branch, head, count)]) + + def insert_activities(self, activities): + """Inserts activities of repo-branches. + + Args: + activities: List of tuple. The tuple must match up with the + argument order specified by ``insert_activity``. + """ + c = self.conn.cursor() + c.executemany( + ''' + INSERT INTO "repo_history" ( + "project", + "url", + "branch", + "head_commit", + "count" + ) + VALUES (?, ?, ?, ?, ?) + ''', + activities + ) + conn.commit() + c.close() + + def list_projects(self): + """Lists loaded projects. + + Repos must be loaded first. + + Yields: + str: Project commit of each project. + """ + c = self.conn.cursor() + try: + for (project,) in c.execute( + '''SELECT DISTINCT "project" FROM "repos" ''' + ): + yield project + finally: + c.close() + + def list_repobranches(self, project_commit): + """Lists repo-branches of a project. + + Repos must be loaded first. + + Results are sorted by recent activity. + + Args: + project_commit: The project commit. + + Yields: + A 3-tuple holding the URI, branch name, and last known head + commit. + """ + c = self.conn.cursor() + try: + for (e, url, branch, head_commit) in c.execute( + ''' + SELECT "max"("e"), "url", "branch", "head_commit" + FROM ( + SELECT + "max"("T1"."entry") "e", + "T1"."url", + "T1"."branch", + "T1"."head_commit" + FROM "repo_history" "T1" + WHERE ( + SELECT "active" + FROM "repos" "T2" + WHERE + "url" = "T1"."url" + AND "branch" IS "T1"."branch" + AND "project" IS ?1 + ) + GROUP BY "T1"."url", "T1"."branch" + UNION + SELECT null, "T3"."url", "T3"."branch", null + FROM "repos" "T3" + WHERE "active" AND "project" IS ?1 + ) + GROUP BY "url" + ORDER BY "e" + ''', + (project_commit,) + ): + yield url, branch, head_commit + finally: + c.close() + + def list_repobranch_activity(self, project_commit, uri, branch): + """Lists activity of a repo-branch. + + Args: + project_commit: The project commit. + uri: The repo uri. + branch: The branch. + + Returns: + list of int: Number of commits between updates. + """ + c = self.conn.cursor() + history = c.execute( + ''' + SELECT "count" + FROM "repo_history" + WHERE + "url" = ? + AND "branch" IS ? + AND "project" IS ? + ORDER BY "entry" ASC + ''', + (url, branch, project) + ).fetchall() + history = [x for [x] in history] + c.close() + return history + + def close(self): + """Closes the database. + """ + self.conn.close() + +def connect_database(effective_config): + """Opens the database specified by the given config. + + Args: + effective_config (ganarchy.data.DataSource): The data source + for the config. + """ + del effective_config # currently unused, intended for the future + conn = sqlite3.connect(ganarchy.dirs.data_home + "/ganarchy.db") + return Database(conn) diff --git a/ganarchy/dirs.py b/ganarchy/dirs.py new file mode 100644 index 0000000..7973126 --- /dev/null +++ b/ganarchy/dirs.py @@ -0,0 +1,53 @@ +# This file is part of GAnarchy - decentralized project hub +# Copyright (C) 2020 Soni L. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +"""This module handles GAnarchy's config, data and cache directories. + +These are not XDG dirs. They're GAnarchy dirs. They're based on XDG +dirs but they're not XDG dirs. + +Attributes: + DATA_HOME (str): GAnarchy data home. + CACHE_HOME (str): GAnarchy cache home. + CONFIG_HOME (str): GAnarchy config home. + CONFIG_DIRS (list of str): GAnarchy config dirs. +""" + +import os + +# need to check for unset or empty, ``.get`` only handles unset. + +DATA_HOME = os.environ.get('XDG_DATA_HOME', '') +if not DATA_HOME: + DATA_HOME = os.environ['HOME'] + '/.local/share' +DATA_HOME = DATA_HOME + "/ganarchy" + +CACHE_HOME = os.environ.get('XDG_CACHE_HOME', '') +if not CACHE_HOME: + CACHE_HOME = os.environ['HOME'] + '/.cache' +CACHE_HOME = CACHE_HOME + "/ganarchy" + +CONFIG_HOME = os.environ.get('XDG_CONFIG_HOME', '') +if not CONFIG_HOME: + CONFIG_HOME = os.environ['HOME'] + '/.config' +CONFIG_HOME = CONFIG_HOME + "/ganarchy" + +CONFIG_DIRS = os.environ.get('XDG_CONFIG_DIRS', '') +if not CONFIG_DIRS: + CONFIG_DIRS = '/etc/xdg' +# TODO check if this is correct +CONFIG_DIRS = [config_dir + "/ganarchy" for config_dir in CONFIG_DIRS.split(':')] + diff --git a/ganarchy/git.py b/ganarchy/git.py new file mode 100644 index 0000000..25be04a --- /dev/null +++ b/ganarchy/git.py @@ -0,0 +1,131 @@ +# This file is part of GAnarchy - decentralized project hub +# Copyright (C) 2020 Soni L. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +"""Git abstraction. +""" + +# Errors are raised when we can't provide an otherwise valid result. +# For example, we return 0 for counts instead of raising, but raise +# instead of returning empty strings for commit hashes and messages. + +import subprocess + +class GitError(LookupError): + """Raised when a git operation fails, generally due to a + missing commit or branch, or network connection issues. + """ + pass + +class Git: + def __init__(self, path): + self.path = path + self.base = ("git", "-C", path) + + def check_history(self, local_head, commit): + """Checks if the local head contains commit in its history. + Raises if it doesn't. + + Args: + local_head (str): Name of local head. + commit (str): Commit hash. + + Raises: + GitError: If an error occurs. + """ + try: + subprocess.check_call( + self.base + ("merge-base", "--is-ancestor", commit, local_head), + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + except subprocess.CalledProcessError as e: + raise GitError from e + + def force_fetch(self, url, remote_head, local_head): + """Fetches a remote head into a local head. + + If the local head already exists, it is replaced. + + Args: + url (str): Remote url. + remote_head (str): Name of remote head. + local_head (str): Name of local head. + + Raises: + GitError: If an error occurs. + """ + try: + subprocess.check_output( + base + ("fetch", "-q", url, "+" + remote_head + ":" + local_head), + stderr=subprocess.STDOUT + ) + except subprocess.CalledProcessError as e: + raise GitError from e + + def get_count(self, first_hash, last_hash): + """Returns a count of the commits added since ``first_hash`` + up to ``last_hash``. + + Args: + first_hash (str): A commit. + last_hash (str): Another commit. + + Returns: + int: A count of commits added between the hashes, or 0 + if an error occurs. + """ + try: + res = subprocess.check_output( + base + ("rev-list", "--count", first_hash + ".." + last_hash, "--"), + stderr=subprocess.DEVNULL + ).decode("utf-8").strip() + return int(res) + except subprocess.CalledProcessError as e: + return 0 + + def get_hash(self, target): + """Returns the commit hash for a given target. + + Args: + target (str): a refspec. + + Raises: + GitError: If an error occurs. + """ + try: + return subprocess.check_output( + self.base + ("show", target, "-s", "--format=format:%H", "--"), + stderr=subprocess.DEVNULL + ).decode("utf-8") + except subprocess.CalledProcessError as e: + raise GitError from e + + def get_commit_message(self, target): + """Returns the commit message for a given target. + + Args: + target (str): a refspec. + + Raises: + GitError: If an error occurs. + """ + try: + return subprocess.check_output( + self.base + ("show", target, "-s", "--format=format:%B", "--"), + stderr=subprocess.DEVNULL + ).decode("utf-8", "replace") + except subprocess.CalledProcessError as e: + raise GitError from e diff --git a/ganarchy/templating/__init__.py b/ganarchy/templating/__init__.py new file mode 100644 index 0000000..d6f6d0c --- /dev/null +++ b/ganarchy/templating/__init__.py @@ -0,0 +1,21 @@ +# This file is part of GAnarchy - decentralized project hub +# Copyright (C) 2020 Soni L. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +"""Templates. + +""" + +# TODO write me diff --git a/ganarchy/templating/environment.py b/ganarchy/templating/environment.py new file mode 100644 index 0000000..a527053 --- /dev/null +++ b/ganarchy/templating/environment.py @@ -0,0 +1,29 @@ +# This file is part of GAnarchy - decentralized project hub +# Copyright (C) 2020 Soni L. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +import jinja2 + +import ganarchy.templating.templates +import ganarchy.templating.toml + +def get_env(): + env = jinja2.Environment( + loader=ganarchy.templating.templates.get_template_loader(), + autoescape=False + ) + env.filters['tomlescape'] = ganarchy.templating.toml.tomlescape + env.filters['tomle'] = env.filters['tomlescape'] + return env diff --git a/ganarchy/templating/templates.py b/ganarchy/templating/templates.py new file mode 100644 index 0000000..0f691e0 --- /dev/null +++ b/ganarchy/templating/templates.py @@ -0,0 +1,124 @@ +# This file is part of GAnarchy - decentralized project hub +# Copyright (C) 2020 Soni L. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +import jinja2 + +import ganarchy.dirs + +def get_template_loader(): + return jinja2.ChoiceLoader([ + jinja2.FileSystemLoader([ganarchy.dirs.CONFIG_HOME + "/templates"] + [config_dir + "/templates" for config_dir in ganarchy.dirs.CONFIG_DIRS]), + jinja2.DictLoader({ + ## index.html + 'index.html': """<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <!-- + GAnarchy - project homepage generator + Copyright (C) 2019 Soni L. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + --> + <title>{{ ganarchy.title|e }}</title> + <meta name="description" content="{{ ganarchy.title|e }}" /> + <!--if your browser doesn't like the following, use a different browser.--> + <script type="application/javascript" src="/index.js"></script> + </head> + <body> + <h1>{{ ganarchy.title|e }}</h1> + <p>This is {{ ganarchy.title|e }}. Currently tracking the following projects:</p> + <ul> + {% for project in ganarchy.projects -%} + <li><a href="/project/{{ project.commit|e }}">{{ project.title|e }}</a>: {{ project.description|e }}</li> + {% endfor -%} + </ul> + <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p> + <p> + <a href="{{ ganarchy.base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>. + </p> + </body> +</html> +""", + ## index.toml + 'index.toml': """# Generated by GAnarchy + +{%- for project, repos in config.projects.items() %} +[projects.{{project}}] +{%- for repo_url, branches in repos.items() %}{% for branch, options in branches.items() %}{% if options.active %} +"{{repo_url|tomle}}".{% if branch %}"{{branch|tomle}}"{% else %}HEAD{% endif %} = { active=true } +{%- endif %}{% endfor %} +{%- endfor %} +{% endfor -%} +""", + ## project.html FIXME + 'project.html': """<!DOCTYPE html> +<html lang="en"> + <head> + <meta charset="utf-8" /> + <!-- + GAnarchy - project homepage generator + Copyright (C) 2019 Soni L. + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + --> + <title>{{ project_title|e }}</title> + {% if project_desc %}<meta name="description" content="{{ project_desc|e }}" />{% endif %} + <style type="text/css">.branchname { color: #808080; font-style: italic; }</style> + </head> + <body> + <h1>{{ project_title|e }}</h1> + <p>Tracking <span id="project_commit"><a href="web+ganarchy:{{ project_commit }}">{{ project_commit }}</a></span></p> + <div id="project_body"><p>{{ project_body|e|replace("\n\n", "</p><p>") }}</p></div> + <ul> + {% for url, msg, img, branch in repos -%} + <li><a href="{{ url|e }}">{{ url|e }}</a>{% if branch %} <span class="branchname">[{{ branch|e }}]</span>{% endif %}: {{ msg|e }}</li> + {% endfor -%} + </ul> + <p>Powered by <a href="https://ganarchy.autistic.space/">GAnarchy</a>. AGPLv3-licensed. <a href="https://cybre.tech/SoniEx2/ganarchy">Source Code</a>.</p> + <p> + <a href="/">Main page</a>. + <a href="{{ base_url|e }}" onclick="event.preventDefault(); navigator.registerProtocolHandler('web+ganarchy', this.href + '?url=%s', 'GAnarchy');">Register web+ganarchy: URI handler</a>. + </p> + </body> +</html> +""", + ## history.svg FIXME + 'history.svg': """""", + }) + ]) diff --git a/ganarchy/templating/toml.py b/ganarchy/templating/toml.py new file mode 100644 index 0000000..431125d --- /dev/null +++ b/ganarchy/templating/toml.py @@ -0,0 +1,33 @@ +# This file is part of GAnarchy - decentralized project hub +# Copyright (C) 2020 Soni L. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +_tomletrans = str.maketrans({ + 0: '\\u0000', 1: '\\u0001', 2: '\\u0002', 3: '\\u0003', 4: '\\u0004', + 5: '\\u0005', 6: '\\u0006', 7: '\\u0007', 8: '\\b', 9: '\\t', 10: '\\n', + 11: '\\u000B', 12: '\\f', 13: '\\r', 14: '\\u000E', 15: '\\u000F', + 16: '\\u0010', 17: '\\u0011', 18: '\\u0012', 19: '\\u0013', 20: '\\u0014', + 21: '\\u0015', 22: '\\u0016', 23: '\\u0017', 24: '\\u0018', 25: '\\u0019', + 26: '\\u001A', 27: '\\u001B', 28: '\\u001C', 29: '\\u001D', 30: '\\u001E', + 31: '\\u001F', '"': '\\"', '\\': '\\\\' + }) + +def tomlescape(value): + """Escapes a string for use in a TOML string. + + Returns: + str: The escaped string. + """ + return value.translate(_tomletrans) |