././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1685805302.8897204 klaus-2.0.3/0000755000076500000000000000000014436654367011024 5ustar00jwheel././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/LICENSE0000644000076500000000000000215214436654075012025 0ustar00jwheelhttps://github.com/jonashaag/klaus Copyright (c) 2011-2013 Jonas Haag and contributors (see Git logs). Favicon: Git Logo by Jason Long , licensed under the Creative Commons Attribution 3.0 Unported License. Feature contributions --------------------- * Werkzeug port -- Martin Zimmermann * Git Smart HTTP support -- Martin Zimmermann Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/MANIFEST.in0000644000076500000000000000012514436654075012554 0ustar00jwheelrecursive-include klaus/static * recursive-include klaus/templates * include klaus.1 ././@PaxHeader0000000000000000000000000000003300000000000010211 xustar0027 mtime=1685805302.889603 klaus-2.0.3/PKG-INFO0000644000076500000000000001144614436654367012127 0ustar00jwheelMetadata-Version: 2.1 Name: klaus Version: 2.0.3 Summary: The first Git web viewer that Just Works™. Home-page: https://github.com/jonashaag/klaus Author: Jonas Haag Author-email: jonas@lophus.org Classifier: Development Status :: 5 - Production/Stable Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application Classifier: Topic :: Software Development :: Version Control Classifier: Environment :: Web Environment Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: ISC License (ISCL) Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 License-File: LICENSE |travis-badge| |gitter-badge| .. |travis-badge| image:: https://travis-ci.org/jonashaag/klaus.svg?branch=master :target: https://travis-ci.org/jonashaag/klaus .. |gitter-badge| image:: https://badges.gitter.im/Join%20Chat.svg :alt: Join the chat at https://gitter.im/jonashaag/klaus :target: https://gitter.im/jonashaag/klaus?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge klaus: a simple, easy-to-set-up Git web viewer that Just Works™. ================================================================ (If it doesn't Just Work for you, please file a bug.) * Super easy to set up -- no configuration required * Syntax highlighting * Markdown + RestructuredText rendering support * Pull + push support (Git Smart HTTP) * Code navigation using Exuberant ctags :Demo: http://klausdemo.lophus.org :Mailing list: http://groups.google.com/group/klaus-users :On PyPI: http://pypi.python.org/pypi/klaus/ :Wiki: https://github.com/jonashaag/klaus/wiki :License: ISC (BSD) Running with Docker -------------------- The easiest way to get started. We maintain a Docker image that has syntax highlighting, Markdown rendering, code navigation, etc. pre-configured:: docker run -v /path/to/your/repos:/repos \ -p 7777:80 \ -it jonashaag/klaus:latest \ klaus --host 0.0.0.0 --port 80 /repos/repo1 /repos/repo2 ... (Replace ``/path/to/your/repos`` with the folder that contains your Git repositories on the Docker host. You can also pass in multiple ``-v`` arguments if your repos are in multiple folders on the host.) Go to http://localhost:7777 on the Docker host et voilà! The command line above simply runs the ``klaus`` script -- for usage details, see the "Using the ``klaus`` script" section below. Local setup ----------- :: pip install klaus (Optional dependencies: see `Markup rendering `_ in the wiki.) Usage ----- See also: `Klaus wiki `_ Using the ``klaus`` script ^^^^^^^^^^^^^^^^^^^^^^^^^^ **NOTE:** This is intended for testing/low-traffic local installations *only*! The `klaus` script uses wsgiref_ internally which doesn't scale *at all* (in fact it's single-threaded and non-asynchronous). To run klaus using the default options: .. code-block:: bash # With Docker: docker run ... jonashaag/klaus:latest klaus [repo1 [repo2 ...]] # Local setup: klaus [repo1 [repo2 ...]] For more options, see: .. code-block:: bash # With Docker: docker run ... jonashaag/klaus:latest klaus --help # Local setup: klaus --help Using a real server ^^^^^^^^^^^^^^^^^^^ The ``klaus`` module contains a ``make_app`` function which returns a WSGI app. An example WSGI helper script is provided with klaus (see ``klaus/contrib/wsgi.py``), configuration being read from environment variables. Use it like this (uWSGI example):: uwsgi -w klaus.contrib.wsgi \ --env KLAUS_SITE_NAME="Klaus Demo" \ --env KLAUS_REPOS="/path/to/repo1 /path/to/repo2 ..." \ ... Gunicorn example:: gunicorn --env KLAUS_SITE_NAME="Klaus Demo" \ --env KLAUS_REPOS="/path/to/repo1 /path/to/repo2 ..." \ klaus.contrib.wsgi The Docker image also has uwsgi preinstalled:: docker run ... jonashaag/klaus:latest uwsgi ... See also `deployment section in the wiki `_. .. _wsgiref: http://docs.python.org/library/wsgiref.html Contributing ------------ Please do it! I'm equally happy with bug reports/feature ideas and code contributions. If you have any questions/issues, I'm happy to help! For starters, `here are a few ideas what to work on. `_ :-) |img1|_ |img2|_ |img3|_ .. |img1| image:: https://i.imgur.com/2XhZIgw.png .. |img2| image:: https://i.imgur.com/6LjC8Cl.png .. |img3| image:: https://i.imgur.com/EYJdQwv.png .. _img1: https://i.imgur.com/MV3uFvw.png .. _img2: https://i.imgur.com/9HEZ3ro.png .. _img3: https://i.imgur.com/kx2HaTq.png ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/README.rst0000644000076500000000000001006714436654075012513 0ustar00jwheel|travis-badge| |gitter-badge| .. |travis-badge| image:: https://travis-ci.org/jonashaag/klaus.svg?branch=master :target: https://travis-ci.org/jonashaag/klaus .. |gitter-badge| image:: https://badges.gitter.im/Join%20Chat.svg :alt: Join the chat at https://gitter.im/jonashaag/klaus :target: https://gitter.im/jonashaag/klaus?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge klaus: a simple, easy-to-set-up Git web viewer that Just Works™. ================================================================ (If it doesn't Just Work for you, please file a bug.) * Super easy to set up -- no configuration required * Syntax highlighting * Markdown + RestructuredText rendering support * Pull + push support (Git Smart HTTP) * Code navigation using Exuberant ctags :Demo: http://klausdemo.lophus.org :Mailing list: http://groups.google.com/group/klaus-users :On PyPI: http://pypi.python.org/pypi/klaus/ :Wiki: https://github.com/jonashaag/klaus/wiki :License: ISC (BSD) Running with Docker -------------------- The easiest way to get started. We maintain a Docker image that has syntax highlighting, Markdown rendering, code navigation, etc. pre-configured:: docker run -v /path/to/your/repos:/repos \ -p 7777:80 \ -it jonashaag/klaus:latest \ klaus --host 0.0.0.0 --port 80 /repos/repo1 /repos/repo2 ... (Replace ``/path/to/your/repos`` with the folder that contains your Git repositories on the Docker host. You can also pass in multiple ``-v`` arguments if your repos are in multiple folders on the host.) Go to http://localhost:7777 on the Docker host et voilà! The command line above simply runs the ``klaus`` script -- for usage details, see the "Using the ``klaus`` script" section below. Local setup ----------- :: pip install klaus (Optional dependencies: see `Markup rendering `_ in the wiki.) Usage ----- See also: `Klaus wiki `_ Using the ``klaus`` script ^^^^^^^^^^^^^^^^^^^^^^^^^^ **NOTE:** This is intended for testing/low-traffic local installations *only*! The `klaus` script uses wsgiref_ internally which doesn't scale *at all* (in fact it's single-threaded and non-asynchronous). To run klaus using the default options: .. code-block:: bash # With Docker: docker run ... jonashaag/klaus:latest klaus [repo1 [repo2 ...]] # Local setup: klaus [repo1 [repo2 ...]] For more options, see: .. code-block:: bash # With Docker: docker run ... jonashaag/klaus:latest klaus --help # Local setup: klaus --help Using a real server ^^^^^^^^^^^^^^^^^^^ The ``klaus`` module contains a ``make_app`` function which returns a WSGI app. An example WSGI helper script is provided with klaus (see ``klaus/contrib/wsgi.py``), configuration being read from environment variables. Use it like this (uWSGI example):: uwsgi -w klaus.contrib.wsgi \ --env KLAUS_SITE_NAME="Klaus Demo" \ --env KLAUS_REPOS="/path/to/repo1 /path/to/repo2 ..." \ ... Gunicorn example:: gunicorn --env KLAUS_SITE_NAME="Klaus Demo" \ --env KLAUS_REPOS="/path/to/repo1 /path/to/repo2 ..." \ klaus.contrib.wsgi The Docker image also has uwsgi preinstalled:: docker run ... jonashaag/klaus:latest uwsgi ... See also `deployment section in the wiki `_. .. _wsgiref: http://docs.python.org/library/wsgiref.html Contributing ------------ Please do it! I'm equally happy with bug reports/feature ideas and code contributions. If you have any questions/issues, I'm happy to help! For starters, `here are a few ideas what to work on. `_ :-) |img1|_ |img2|_ |img3|_ .. |img1| image:: https://i.imgur.com/2XhZIgw.png .. |img2| image:: https://i.imgur.com/6LjC8Cl.png .. |img3| image:: https://i.imgur.com/EYJdQwv.png .. _img1: https://i.imgur.com/MV3uFvw.png .. _img2: https://i.imgur.com/9HEZ3ro.png .. _img3: https://i.imgur.com/kx2HaTq.png ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1685805302.8864071 klaus-2.0.3/klaus/0000755000076500000000000000000014436654367012143 5ustar00jwheel././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805273.0 klaus-2.0.3/klaus/__init__.py0000644000076500000000000002300014436654331014236 0ustar00jwheelimport jinja2 try: import jinja2.ext.autoescape jinja2_autoescape_builtin = False except ImportError: jinja2_autoescape_builtin = True import flask import httpauth import dulwich.web from dulwich.errors import NotGitRepository from klaus import views, utils from klaus.repo import FancyRepo, InvalidRepo KLAUS_VERSION = utils.guess_git_revision() or "2.0.3" class Klaus(flask.Flask): jinja_options = { "extensions": [] if jinja2_autoescape_builtin else ["jinja2.ext.autoescape"], "undefined": jinja2.StrictUndefined, } def __init__(self, repo_paths, site_name, use_smarthttp, ctags_policy="none"): """(See `make_app` for parameter descriptions.)""" self.site_name = site_name self.use_smarthttp = use_smarthttp self.ctags_policy = ctags_policy valid_repos, invalid_repos = self.load_repos(repo_paths) self.valid_repos = {repo.namespaced_name: repo for repo in valid_repos} self.invalid_repos = {repo.namespaced_name: repo for repo in invalid_repos} flask.Flask.__init__(self, __name__) self.setup_routes() def create_jinja_environment(self): """Called by Flask.__init__""" env = super(Klaus, self).create_jinja_environment() for func in [ "force_unicode", "timesince", "shorten_sha1", "shorten_message", "extract_author_name", "formattimestamp", ]: env.filters[func] = getattr(utils, func) env.globals["KLAUS_VERSION"] = KLAUS_VERSION env.globals["USE_SMARTHTTP"] = self.use_smarthttp env.globals["SITE_NAME"] = self.site_name return env def setup_routes(self): # fmt: off for endpoint, rule in [ ('repo_list', '/'), ('robots_txt', '/robots.txt/'), ('blob', '//blob/'), ('blob', '//blob//'), ('blame', '//blame/'), ('blame', '//blame//'), ('raw', '//raw//'), ('raw', '//raw//'), ('submodule', '//submodule//'), ('submodule', '//submodule//'), ('commit', '//commit//'), ('patch', '//commit/.diff'), ('patch', '//commit/.patch'), ('index', '//'), ('index', '//'), ('history', '//tree//'), ('history', '//tree//'), ('download', '//tarball//'), ]: self.add_url_rule(rule, view_func=getattr(views, endpoint)) if "" in rule: self.add_url_rule( "/~" + rule, view_func=getattr(views, endpoint) ) # fmt: on def should_use_ctags(self, git_repo, git_commit): if self.ctags_policy == "none": return False elif self.ctags_policy == "ALL": return True elif self.ctags_policy == "tags-and-branches": return git_commit.id in git_repo.get_tag_and_branch_shas() else: raise ValueError("Unknown ctags policy %r" % self.ctags_policy) def load_repos(self, repo_paths): valid_repos = [] invalid_repos = [] for namespace, paths in repo_paths.items(): for path in paths: try: valid_repos.append(FancyRepo(path, namespace)) except NotGitRepository: invalid_repos.append(InvalidRepo(path, namespace)) return valid_repos, invalid_repos def make_app( repo_paths, site_name, use_smarthttp=False, htdigest_file=None, require_browser_auth=False, disable_push=False, unauthenticated_push=False, ctags_policy="none", ): """ Returns a WSGI app with all the features (smarthttp, authentication) already patched in. :param repo_paths: Repositories to serve. This can either be a list of paths or dictionary of the following form: { "namespace1": [list of paths of repositories], "namespace2": [list of paths of repositories], ... None: [list of paths of repositories without namespace] } :param site_name: Name of the Web site (e.g. "John Doe's Git Repositories") :param use_smarthttp: Enable Git Smart HTTP mode, which makes it possible to pull from the served repositories. If `htdigest_file` is set as well, also allow to push for authenticated users. :param require_browser_auth: Require HTTP authentication according to the credentials in `htdigest_file` for ALL access to the Web interface. Requires the `htdigest_file` option to be set. :param disable_push: Disable push support. This is required in case both `use_smarthttp` and `require_browser_auth` (and thus `htdigest_file`) are set, but push should not be supported. :param htdigest_file: A *file-like* object that contains the HTTP auth credentials. :param unauthenticated_push: Allow push'ing without authentication. DANGER ZONE! :param ctags_policy: The ctags policy to use, may be one of: - 'none': never use ctags - 'tags-and-branches': use ctags for revisions that are the HEAD of a tag or branc - 'ALL': use ctags for all revisions, may result in high server load! """ if unauthenticated_push: if not use_smarthttp: raise ValueError("'unauthenticated_push' set without 'use_smarthttp'") if disable_push: raise ValueError("'unauthenticated_push' set with 'disable_push'") if require_browser_auth: raise ValueError( "Incompatible options 'unauthenticated_push' and 'require_browser_auth'" ) if htdigest_file and not (require_browser_auth or use_smarthttp): raise ValueError( "'htdigest_file' set without 'use_smarthttp' or 'require_browser_auth'" ) if not isinstance(repo_paths, dict): # If repos is given as a flat list, put all repos under the "no namespace" namespace repo_paths = {None: repo_paths} app = Klaus( repo_paths, site_name, use_smarthttp, ctags_policy, ) app.wsgi_app = utils.ProxyFix(app.wsgi_app) if use_smarthttp: # `path -> Repo` mapping for Dulwich's web support dulwich_backend = dulwich.server.DictBackend( { "/" + namespaced_name: repo for namespaced_name, repo in app.valid_repos.items() } ) # Dulwich takes care of all Git related requests/URLs # and passes through everything else to klaus dulwich_wrapped_app = dulwich.web.make_wsgi_chain( backend=dulwich_backend, fallback_app=app.wsgi_app, ) dulwich_wrapped_app = utils.ProxyFix(dulwich_wrapped_app) # `receive-pack` is requested by the "client" on a push # (the "server" is asked to *receive* packs), i.e. we need to secure # it using authentication or deny access completely to make the repo # read-only. # # Git first sends requests to //info/refs?service=git-receive-pack. # If this request is responded to using HTTP 401 Unauthorized, the user # is prompted for username and password. If we keep responding 401, Git # interprets this as an authentication failure. (We can't respond 403 # because this results in horrible, unhelpful Git error messages.) # # Git will never call //git-receive-pack if authentication # failed for /info/refs, but since it's used to upload stuff to the server # we must secure it anyway for security reasons. PATTERN = ( r"^/(~[^/]+/)?[^/]+/(info/refs\?service=git-receive-pack|git-receive-pack)$" ) if unauthenticated_push: # DANGER ZONE: Don't require authentication for push'ing app.wsgi_app = dulwich_wrapped_app elif htdigest_file and not disable_push: # .htdigest file given. Use it to read the push-er credentials from. if require_browser_auth: # No need to secure push'ing if we already require HTTP auth # for all of the Web interface. app.wsgi_app = dulwich_wrapped_app else: # Web interface isn't already secured. Require authentication for push'ing. app.wsgi_app = httpauth.DigestFileHttpAuthMiddleware( htdigest_file, wsgi_app=dulwich_wrapped_app, routes=[PATTERN], ) else: # No .htdigest file given. Disable push-ing. Semantically we should # use HTTP 403 here but since that results in freaky error messages # (see above) we keep asking for authentication (401) instead. # Git will print a nice error message after a few tries. app.wsgi_app = httpauth.AlwaysFailingAuthMiddleware( wsgi_app=dulwich_wrapped_app, routes=[PATTERN], ) if require_browser_auth: app.wsgi_app = httpauth.DigestFileHttpAuthMiddleware( htdigest_file, wsgi_app=app.wsgi_app ) return app ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/cli.py0000644000076500000000000001026714436654075013266 0ustar00jwheel# coding: utf-8 from __future__ import print_function import sys import os import argparse import webbrowser from dulwich.errors import NotGitRepository from dulwich.repo import Repo from klaus import make_app, KLAUS_VERSION from klaus.utils import force_unicode def git_repository(path): path = os.path.abspath(path) if not os.path.exists(path): raise argparse.ArgumentTypeError("%r: No such directory" % path) try: Repo(path) except NotGitRepository: raise argparse.ArgumentTypeError("%r: Not a Git repository" % path) return path def make_parser(): parser = argparse.ArgumentParser() parser.add_argument("--host", help="default: 127.0.0.1", default="127.0.0.1") parser.add_argument("--port", help="default: 8080", default=8080, type=int) parser.add_argument( "--site-name", help="site name showed in header. default: your hostname" ) parser.add_argument("--version", help="print version number", action="store_true") parser.add_argument( "-b", "--browser", help="open klaus in a browser on server start", default=False, action="store_true", ) parser.add_argument( "-B", "--with-browser", help="specify which browser to use with --browser", metavar="BROWSER", default=None, ) parser.add_argument( "--ctags", help="enable ctags for which revisions? default: none. " "WARNING: Don't use 'ALL' for public servers!", choices=["none", "tags-and-branches", "ALL"], default="none", ) parser.add_argument( "repos", help="repositories to serve", metavar="DIR", nargs="*", type=git_repository, ) grp = parser.add_argument_group("Git Smart HTTP") grp.add_argument( "--smarthttp", help="enable Git Smart HTTP serving", action="store_true" ) grp.add_argument( "--htdigest", help="use credentials from FILE", metavar="FILE", type=argparse.FileType("r"), ) grp = parser.add_argument_group("Development flags", "DO NOT USE IN PRODUCTION!") grp.add_argument( "--debug", help="Enable Werkzeug debugger and reloader", action="store_true" ) return parser def main(): args = make_parser().parse_args() if args.version: print(KLAUS_VERSION) return 0 if args.htdigest and not args.smarthttp: print( "ERROR: --htdigest option has no effect without --smarthttp enabled", file=sys.stderr, ) return 1 if not args.repos: print( "WARNING: No repositories supplied -- syntax is 'klaus dir1 dir2...'.", file=sys.stderr, ) if not args.site_name: args.site_name = "%s:%d" % (args.host, args.port) if args.ctags != "none": from klaus.ctagsutils import check_have_exuberant_ctags if not check_have_exuberant_ctags(): print( "ERROR: Exuberant ctags not installed (or 'ctags' binary isn't *Exuberant* ctags)", file=sys.stderr, ) return 1 try: import ctags except ImportError: raise ImportError("Please install 'python-ctags3' to enable ctags support.") app = make_app( args.repos, force_unicode(args.site_name or args.host), args.smarthttp, args.htdigest, ctags_policy=args.ctags, ) if args.browser: _open_browser(args) app.run(args.host, args.port, args.debug) def _open_browser(args): # Open a web browser onto the server URL. Technically we're jumping the # gun a little here since the server is not yet running, but there's no # clean way to run a function after the server has started without # losing the simplicity of the code. In the Real World (TM) it'll take # longer for the browser to start than it will for us to start # serving, so we'll be OK. if args.with_browser is None: opener = webbrowser.open else: opener = webbrowser.get(args.with_browser).open opener("http://%s:%s" % (args.host, args.port)) if __name__ == "__main__": exit(main()) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1685805302.8875592 klaus-2.0.3/klaus/contrib/0000755000076500000000000000000014436654367013603 5ustar00jwheel././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/contrib/__init__.py0000644000076500000000000000000014436654075015676 0ustar00jwheel././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/contrib/app_args.py0000644000076500000000000000142514436654075015747 0ustar00jwheelimport os from distutils.util import strtobool def get_args_from_env(): repos = os.environ.get("KLAUS_REPOS", []) if repos: repos = repos.split() args = (repos, os.environ.get("KLAUS_SITE_NAME", "unnamed site")) kwargs = dict( htdigest_file=os.environ.get("KLAUS_HTDIGEST_FILE"), use_smarthttp=strtobool(os.environ.get("KLAUS_USE_SMARTHTTP", "0")), require_browser_auth=strtobool( os.environ.get("KLAUS_REQUIRE_BROWSER_AUTH", "0") ), disable_push=strtobool(os.environ.get("KLAUS_DISABLE_PUSH", "0")), unauthenticated_push=strtobool( os.environ.get("KLAUS_UNAUTHENTICATED_PUSH", "0") ), ctags_policy=os.environ.get("KLAUS_CTAGS_POLICY", "none"), ) return args, kwargs ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/contrib/wsgi.py0000644000076500000000000000047514436654075015130 0ustar00jwheelfrom klaus import make_app from .app_args import get_args_from_env args, kwargs = get_args_from_env() if kwargs["htdigest_file"]: with open(kwargs["htdigest_file"]) as file: kwargs["htdigest_file"] = file application = make_app(*args, **kwargs) else: application = make_app(*args, **kwargs) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/contrib/wsgi_autoreload.py0000644000076500000000000000144414436654075017344 0ustar00jwheelimport os import warnings import io from .app_args import get_args_from_env from .wsgi_autoreloading import make_autoreloading_app if "KLAUS_REPOS" in os.environ: warnings.warn( "use KLAUS_REPOS_ROOT instead of KLAUS_REPOS for the autoreloader apps", DeprecationWarning, ) args, kwargs = get_args_from_env() repos_root = os.environ.get("KLAUS_REPOS_ROOT") or os.environ["KLAUS_REPOS"] args = (repos_root,) + args[1:] if kwargs["htdigest_file"]: # Cache the contents of the htdigest file, the application will not read # the file like object until later when called. with io.open(kwargs["htdigest_file"], encoding="utf-8") as htdigest_file: kwargs["htdigest_file"] = io.StringIO(htdigest_file.read()) application = make_autoreloading_app(*args, **kwargs) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/contrib/wsgi_autoreloading.py0000644000076500000000000000267214436654075020046 0ustar00jwheelfrom __future__ import print_function import glob import time import threading from klaus import make_app # Shared state between poller and application wrapper class S: #: the real WSGI app inner_app = None should_reload = True def poll_for_changes(interval, dir): """ Polls `dir` for changes every `interval` seconds and sets `should_reload` accordingly. """ glob_pattern = dir + "/*" old_contents = glob.glob(glob_pattern) while 1: time.sleep(interval) if S.should_reload: # klaus application has not seen our change yet continue new_contents = glob.glob(glob_pattern) if new_contents != old_contents: # Directory contents changed => should_reload old_contents = new_contents S.should_reload = True def make_autoreloading_app(repos_root, *args, **kwargs): def app(environ, start_response): if S.should_reload: # Refresh inner application with new repo list print("Reloading repository list...") S.inner_app = make_app(glob.glob(repos_root + "/*"), *args, **kwargs) S.should_reload = False return S.inner_app(environ, start_response) # Background thread that polls the directory for changes poller_thread = threading.Thread(target=(lambda: poll_for_changes(10, repos_root))) poller_thread.daemon = True poller_thread.start() return app ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/ctagscache.py0000644000076500000000000001660714436654075014610 0ustar00jwheel"""A cache for tagsfiles generated by the 'ctags' command line tool. We don't want to run the 'ctags' command line tool on each request as it may take a lot of time. The following steps are necessary in order to create a ctags tagsfile that be read by Pygments: 1. Clone the repository to a temporary location and check out the branch/commit the user is browsing, unless the branch is already checked out. (*) 2. Run 'ctags -R' on the temporary repository checkout. 3. Delete the temporary repository checkout. To avoid going through these steps on each request, we cache the tagsfile generated in step 2. The cache is on-disk and non-persistent, i.e. cleared whenever the Python interpreter running klaus is shut down. For large projects, the ctags tagsfiles may grow to sizes of multiple MiB, so we have to set an upper limit on the size of the cache. Since tagsfiles are represented as uncompressed ASCII files, we can increase the number of tagsfiles we can cache by using compression. Of course, 'python-ctags', which is used by Pygments to read the tagsfiles, can't deal with compressed tagsfiles, so we have to uncompress them before actually using them. To avoid decompressing tagsfiles on each request, we keep the tagsfiles that are most likely to be used (**) in uncompressed form. (*) We always create a clone in the current implementation; this could be optimized in the future. (**) "most likely": currently implemented as "most recently used" """ import os import shutil import tempfile import threading import gzip import atexit from dulwich.lru_cache import LRUSizeCache from klaus.ctagsutils import create_tagsfile, delete_tagsfile # Good compression while taking only 10% more time than level 1 COMPRESSION_LEVEL = 4 def compress_tagsfile(uncompressed_tagsfile_path): """Compress an uncompressed tagsfile. :return: path to the compressed version of the tagsfile """ _, compressed_tagsfile_path = tempfile.mkstemp() with open(uncompressed_tagsfile_path, "rb") as uncompressed: with gzip.open(compressed_tagsfile_path, "wb", COMPRESSION_LEVEL) as compressed: shutil.copyfileobj(uncompressed, compressed) return compressed_tagsfile_path def uncompress_tagsfile(compressed_tagsfile_path): """Uncompress an compressed tagsfile. :return: path to the uncompressed version of the tagsfile """ _, uncompressed_tagsfile_path = tempfile.mkstemp() with gzip.open(compressed_tagsfile_path, "rb") as compressed: with open(uncompressed_tagsfile_path, "wb") as uncompressed: shutil.copyfileobj(compressed, uncompressed) return uncompressed_tagsfile_path MiB = 1024 * 1024 class CTagsCache(object): """A ctags cache. Both uncompressed and compressed entries are kept in temporary files created by `tempfile.mkstemp` which are deleted from disk when the Python interpreter is shut down. :param uncompressed_max_bytes: Maximum size of the uncompressed cache sector :param compressed_max_bytes: Maximum size of the compressed cache sector The lifecycle of a cache entry is as follows. - When first created, a tagsfile is put into the uncompressed cache sector. - When free space is required for other uncompressed tagsfiles, it may be moved to the compressed cache sector. Gzip is used to compress the tagsfile. - When free space is required for other compressed tagsfiles, it may be evicted from the cache entirely. - When the tagsfile is requested and it's in the compressed cache sector, it is moved back to the uncompressed sector prior to using it. """ def __init__(self, uncompressed_max_bytes=30 * MiB, compressed_max_bytes=20 * MiB): self.uncompressed_max_bytes = uncompressed_max_bytes self.compressed_max_bytes = compressed_max_bytes # Note: We use dulwich's LRU cache to store the tagsfile paths here, # but we could easily replace it by any other (LRU) cache implementation. self._uncompressed_cache = LRUSizeCache( uncompressed_max_bytes, compute_size=os.path.getsize ) self._compressed_cache = LRUSizeCache( compressed_max_bytes, compute_size=os.path.getsize ) self._clearing = False self._lock = threading.Lock() atexit.register(self.clear) def __del__(self): self.clear() def clear(self): """Clear both the uncompressed and compressed caches.""" # Don't waste time moving tagsfiles from uncompressed to compressed cache, # but remove them directly instead: self._clearing = True self._uncompressed_cache.clear() self._compressed_cache.clear() self._clearing = False def get_tagsfile(self, git_repo_path, git_rev): """Get the ctags tagsfile for the given Git repository and revision. - If the tagsfile is still in cache, and in uncompressed form, return it without any further cost. - If the tagsfile is still in cache, but in compressed form, uncompress it, put it into uncompressed space, and return the uncompressed version. - If the tagsfile isn't in cache at all, create it, put it into uncompressed cache and return the newly created version. """ # Always require full SHAs assert len(git_rev) == 40 # Avoiding race conditions, The Sledgehammer Way with self._lock: if git_rev in self._uncompressed_cache: return self._uncompressed_cache[git_rev] if git_rev in self._compressed_cache: compressed_tagsfile_path = self._compressed_cache[git_rev] uncompressed_tagsfile_path = uncompress_tagsfile( compressed_tagsfile_path ) self._compressed_cache._remove_node( self._compressed_cache._cache[git_rev] ) else: # Not in cache. uncompressed_tagsfile_path = create_tagsfile(git_repo_path, git_rev) self._uncompressed_cache.add( git_rev, uncompressed_tagsfile_path, self._clear_uncompressed_entry ) return uncompressed_tagsfile_path def _clear_uncompressed_entry(self, git_rev, uncompressed_tagsfile_path): """Called by LRUSizeCache whenever an entry is to be evicted from uncompressed cache. Most of the times this happens when space is needed in uncompressed cache, in which case we move the tagsfile to compressed cache. When clearing the cache, we don't bother moving entries to uncompressed space; we delete them directly instead. """ if not self._clearing: # If we're clearing the whole cache, don't waste time moving tagsfiles # from uncompressed to compressed cache, but remove them directly instead. self._compressed_cache.add( git_rev, compress_tagsfile(uncompressed_tagsfile_path), self._clear_compressed_entry, ) delete_tagsfile(uncompressed_tagsfile_path) def _clear_compressed_entry(self, git_rev, compressed_tagsfile_path): """Called by LRUSizeCache whenever an entry to be evicted from compressed cache. This happens when space is needed for new compressed tagsfiles. We delete the evictee from the cache entirely. """ delete_tagsfile(compressed_tagsfile_path) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/ctagsutils.py0000644000076500000000000000257414436654075014703 0ustar00jwheelimport os import subprocess import shutil import tempfile import subprocess def check_have_exuberant_ctags(): """Check that the 'ctags' binary is *Exuberant* ctags (not etags etc)""" try: return b"Exuberant" in subprocess.check_output( ["ctags", "--version"], stderr=subprocess.PIPE ) except subprocess.CalledProcessError: return False def create_tagsfile(git_repo_path, git_rev): """Create a ctags tagsfile for the given Git repository and revision. This creates a temporary clone of the repository, checks out the revision, runs 'ctags -R' and deletes the temporary clone. :return: path to the generated tagsfile """ assert ( check_have_exuberant_ctags() ), "'ctags' binary is missing or not *Exuberant* ctags" _, target_tagsfile = tempfile.mkstemp() checkout_tmpdir = tempfile.mkdtemp() try: subprocess.check_call( ["git", "clone", "-q", "--shared", git_repo_path, checkout_tmpdir] ) subprocess.check_call(["git", "checkout", "-q", git_rev], cwd=checkout_tmpdir) subprocess.check_call( ["ctags", "--fields=+l", "-Rno", target_tagsfile], cwd=checkout_tmpdir ) finally: shutil.rmtree(checkout_tmpdir) return target_tagsfile def delete_tagsfile(tagsfile_path): """Delete a tagsfile.""" os.remove(tagsfile_path) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/diff.py0000644000076500000000000000505514436654075013426 0ustar00jwheel# -*- coding: utf-8 -*- """ lodgeit.lib.diff ~~~~~~~~~~~~~~~~ Render a nice diff between two things. :copyright: 2007 by Armin Ronacher. :license: BSD """ from difflib import SequenceMatcher from klaus.utils import escape_html as e def highlight_line(old_line, new_line): """Highlight inline changes in both lines.""" start = 0 limit = min(len(old_line), len(new_line)) while start < limit and old_line[start] == new_line[start]: start += 1 end = -1 limit -= start while -end <= limit and old_line[end] == new_line[end]: end -= 1 end += 1 if start or end: def do(l, tag): last = end + len(l) return b"".join( [l[:start], b"<", tag, b">", l[start:last], b"", l[last:]] ) old_line = do(old_line, b"del") new_line = do(new_line, b"ins") return old_line, new_line def render_diff(a, b, n=3): """Parse the diff an return data for the template.""" actions = [] chunks = [] for group in SequenceMatcher(None, a, b).get_grouped_opcodes(n): old_line, old_end, new_line, new_end = ( group[0][1], group[-1][2], group[0][3], group[-1][4], ) lines = [] def add_line(old_lineno, new_lineno, action, line): actions.append(action) lines.append( { "old_lineno": old_lineno, "new_lineno": new_lineno, "action": action, "line": line, "no_newline": not line.endswith(b"\n"), } ) chunks.append(lines) for tag, i1, i2, j1, j2 in group: if tag == "equal": for c, line in enumerate(a[i1:i2]): add_line(i1 + c, j1 + c, "unmod", e(line)) elif tag == "insert": for c, line in enumerate(b[j1:j2]): add_line(None, j1 + c, "add", e(line)) elif tag == "delete": for c, line in enumerate(a[i1:i2]): add_line(i1 + c, None, "del", e(line)) elif tag == "replace": for c, line in enumerate(a[i1:i2]): add_line(i1 + c, None, "del", e(line)) for c, line in enumerate(b[j1:j2]): add_line(None, j1 + c, "add", e(line)) else: raise AssertionError("unknown tag %s" % tag) return actions.count("add"), actions.count("del"), chunks ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/highlighting.py0000644000076500000000000001117014436654075015156 0ustar00jwheeltry: # Python < 3 from itertools import ifilter as filter except ImportError: pass from pygments import highlight from pygments.lexers import ( get_lexer_by_name, get_lexer_for_filename, guess_lexer, ClassNotFound, TextLexer, ) from pygments.formatters import HtmlFormatter from klaus import markup CTAGS_SUPPORTED_LANGUAGES = ( "Asm Awk Basic C C# C++ Cobol DosBatch Eiffel Erlang Fortran HTML Java " "JavaScript Lisp Lua Make Makefile MatLab OCaml PHP Pascal Perl Python " "REXX Ruby SML SQL Scheme Sh Tcl Tex VHDL Verilog Vim" # Not supported by Pygments: Asp Ant BETA Flex SLang Vera YACC ).split() PYGMENTS_CTAGS_LANGUAGE_MAP = dict( (get_lexer_by_name(l).name, l) for l in CTAGS_SUPPORTED_LANGUAGES ) class KlausDefaultFormatter(HtmlFormatter): def __init__(self, language, ctags, **kwargs): HtmlFormatter.__init__( self, linenos="table", lineanchors="L", linespans="L", anchorlinenos=True, **kwargs ) self.language = language if ctags: # Use Pygments' ctags system but provide our own CTags instance self.tagsfile = True # some trueish object self._ctags = ctags def _format_lines(self, tokensource): for tag, line in HtmlFormatter._format_lines(self, tokensource): if tag == 1: # sourcecode line line = "%s" % line yield tag, line def _lookup_ctag(self, token): matches = list(self._get_all_ctags_matches(token)) best_matches = list(self.get_best_ctags_matches(matches)) if not best_matches: return None, None else: return ( best_matches[0]["file"].decode("utf-8"), best_matches[0]["lineNumber"], ) def _get_all_ctags_matches(self, token): FIELDS = ("file", "lineNumber", "kind", b"language") from ctags import TagEntry entry = TagEntry() # target "buffer" for ctags if self._ctags.find(entry, token.encode("utf-8"), 0): yield dict((k, entry[k]) for k in FIELDS) while self._ctags.findNext(entry): yield dict((k, entry[k]) for k in FIELDS) def get_best_ctags_matches(self, matches): if self.language is None: return matches else: return filter( lambda match: match[b"language"] == self.language.encode("utf-8"), matches, ) class KlausPythonFormatter(KlausDefaultFormatter): def get_best_ctags_matches(self, matches): # The first ctags match may be an import, which ctags sees as a # definition of the tag -- even though it might very well have found # the "real" definition of the tag. Import matches aren't very helpful: # In the best case, we are brought to the line where the tag is imported # in the same file. But it may also bring us to some completely unrelated # import of the tag in some other file. We change the tag lookup mechanics # so that non-import matches are always preferred over import matches. return filter( lambda match: match["kind"] != b"i", super(KlausPythonFormatter, self).get_best_ctags_matches(matches), ) def highlight_or_render( code, filename, render_markup=True, ctags=None, ctags_baseurl=None ): """Render code using Pygments, markup (markdown, rst, ...) using the corresponding renderer, if available. :param code: the program code to highlight, str :param filename: name of the source file the code is taken from, str :param render_markup: whether to render markup if possible, bool :param ctags: tagsfile obj used for source code hyperlinks, ``ctags.CTags`` :param ctags_baseurl: base url used for source code hyperlinks, str """ if render_markup and markup.can_render(filename): return markup.render(filename, code) try: lexer = get_lexer_for_filename(filename, code) except ClassNotFound: try: lexer = guess_lexer(code) except ClassNotFound: lexer = TextLexer() formatter_cls = { "Python": KlausPythonFormatter, }.get(lexer.name, KlausDefaultFormatter) if ctags: ctags_urlscheme = ctags_baseurl + "%(path)s%(fname)s%(fext)s" else: ctags_urlscheme = None formatter = formatter_cls( language=PYGMENTS_CTAGS_LANGUAGE_MAP.get(lexer.name), ctags=ctags, tagurlformat=ctags_urlscheme, ) return highlight(code, lexer, formatter) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/markup.py0000644000076500000000000000246114436654075014013 0ustar00jwheelimport os LANGUAGES = [] def get_renderer(filename): _, ext = os.path.splitext(filename) for extensions, renderer in LANGUAGES: if ext in extensions: return renderer def can_render(filename): return get_renderer(filename) is not None def render(filename, content=None): if content is None: content = open(filename).read() return get_renderer(filename)(content) def _load_markdown(): try: import markdown except ImportError: return def render_markdown(content): return markdown.markdown(content, extensions=["toc", "extra"]) LANGUAGES.append(([".md", ".mkdn", ".mdwn", ".markdown"], render_markdown)) def _load_restructured_text(): try: from docutils.core import publish_parts from docutils.writers.html4css1 import Writer except ImportError: return def render_rest(content): # start by h2 and ignore invalid directives and so on # (most likely from Sphinx) settings = {"initial_header_level": 2, "report_level": 0} return publish_parts(content, writer=Writer(), settings_overrides=settings).get( "html_body" ) LANGUAGES.append(([".rst", ".rest"], render_rest)) for loader in [_load_markdown, _load_restructured_text]: loader() ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/repo.py0000644000076500000000000003503614436654075013465 0ustar00jwheelimport functools import io import os import stat import subprocess import threading from dulwich.objects import S_ISGITLINK from dulwich.object_store import tree_lookup_path from dulwich.objects import Blob from dulwich.errors import NotTreeError import dulwich, dulwich.patch try: from dulwich.refs import SymrefLoop except ImportError: # dulwich < 0.20.46 InaccessibleRef = KeyError else: InaccessibleRef = (SymrefLoop, KeyError) from klaus.utils import ( force_unicode, parent_directory, repo_human_name, encode_for_git, decode_from_git, ) from klaus.diff import render_diff NOT_SET = "__not_set__" def cached_call(key, validator, producer, _cache={}): data, old_validator = _cache.get(key, (None, NOT_SET)) if old_validator != validator: data = producer() _cache[key] = (data, validator) return data def synchronized(func, lock=threading.RLock()): @functools.wraps(func) def synchronized_func(*args, **kwargs): with lock: return func(*args, **kwargs) return synchronized_func class FancyRepo(object): """A wrapper around Dulwich's Repo that adds some helper methods.""" def __init__(self, path, namespace): self.dulwich_repo = dulwich.repo.Repo(path) self.namespace = namespace @property def path(self): return self.dulwich_repo.path @property def name(self): return repo_human_name(self.path) @property def namespaced_name(self): if self.namespace: return "~{}/{}".format(self.namespace, self.name) else: return self.name @synchronized def __getitem__(self, key): return self.dulwich_repo[key] # TODO: factor out stuff into dulwich @synchronized def get_last_updated_at(self): """Get datetime of last commit to this repository. Caches the result to speed up the repo_list page. Cache is invalidated if one of the ref targets changes, eg. a new commit has been made and 'refs/heads/master' was changed. """ def _get_commit_time_cached(ref_id): return cached_call( key=(ref_id, "_get_commit_time"), validator=None, producer=lambda: _get_commit_time(ref_id), ) def _get_commit_time(ref_id): try: return self[ref_id].commit_time except (KeyError, AttributeError): # Missing or non-commit object return None max_refs = 1000 if len(self.dulwich_repo.refs.keys()) > max_refs: # If we have too many refs, look at the branches only. (And HEAD, see below.) base = b"refs/heads" else: base = None all_ids = list(self.dulwich_repo.refs.as_dict(base).values()) # If we still have too many refs, keep only some. if len(all_ids) > max_refs: all_ids = sorted(all_ids)[:max_refs] # Always add HEAD. try: all_ids.append(self.dulwich_repo.refs[b"HEAD"]) except KeyError: pass commit_times = filter(None, map(_get_commit_time_cached, all_ids)) try: return max(commit_times) except ValueError: # Python 2 does not support max(..., default=) return None @property @synchronized def cloneurl(self): """Retrieve the gitweb notion of the public clone URL of this repo.""" f = self.dulwich_repo.get_named_file("cloneurl") if f is not None: return force_unicode(f.read()) c = self.dulwich_repo.get_config() try: return force_unicode(c.get(b"gitweb", b"url")) except KeyError: return None @synchronized def get_description(self): """Like Dulwich's `get_description`, but returns None if the file contains Git's default text "Unnamed repository[...]". """ # Cache result to speed up repo_list.html template. # If description file mtime has changed, we should invalidate the cache. description_file = os.path.join(self.dulwich_repo._controldir, "description") try: description_mtime = os.stat(description_file).st_mtime except OSError: description_mtime = None return cached_call( key=(id(self), "get_description"), validator=description_mtime, producer=self._get_description, ) def _get_description(self): description = self.dulwich_repo.get_description() if description: description = force_unicode(description) if not description.startswith("Unnamed repository;"): return force_unicode(description) @synchronized def get_commit(self, rev): """Get commit object identified by `rev` (SHA or branch or tag name).""" for prefix in ["refs/heads/", "refs/tags/", ""]: key = prefix + rev try: obj = self[encode_for_git(key)] if isinstance(obj, dulwich.objects.Tag): obj = self[obj.object[1]] return obj except KeyError: pass raise KeyError(rev) @synchronized def get_default_branch(self): """Tries to guess the default repo branch name.""" for candidate in ["master", "main", "trunk", "default", "gh-pages"]: try: self.get_commit(candidate) return candidate except InaccessibleRef: pass return None @synchronized def get_ref_names_ordered_by_last_commit(self, prefix, exclude=None): """Return a list of ref names that begin with `prefix`, ordered by the time they have been committed to last. """ def get_commit_time(refname): try: obj = self[refs[refname]] except InaccessibleRef: # Default to 0, i.e. sorting refs that point at non-existant # objects last. return 0 if isinstance(obj, dulwich.objects.Tag): return obj.tag_time return obj.commit_time refs = self.dulwich_repo.refs.as_dict(encode_for_git(prefix)) if exclude: refs.pop(prefix + exclude, None) sorted_names = sorted(refs.keys(), key=get_commit_time, reverse=True) return [decode_from_git(ref) for ref in sorted_names] @synchronized def get_branch_names(self, exclude=None): """Return a list of branch names of this repo, ordered by the time they have been committed to last. """ return self.get_ref_names_ordered_by_last_commit("refs/heads", exclude) @synchronized def get_tag_names(self): """Return a list of tag names of this repo, ordered by creation time.""" return self.get_ref_names_ordered_by_last_commit("refs/tags") @synchronized def get_tag_and_branch_shas(self): """Return a list of SHAs of all tags and branches.""" tag_shas = self.dulwich_repo.refs.as_dict(b"refs/tags/").values() branch_shas = self.dulwich_repo.refs.as_dict(b"refs/heads/").values() return set(tag_shas) | set(branch_shas) @synchronized def history(self, commit, path=None, max_commits=None, skip=0): """Return a list of all commits that affected `path`, starting at branch or commit `commit`. `skip` can be used for pagination, `max_commits` to limit the number of commits returned. Similar to `git log [branch/commit] [--skip skip] [-n max_commits]`. """ # XXX The pure-Python/dulwich code is very slow compared to `git log` # at the time of this writing (mid-2012). # For instance, `git log .tx` in the Django root directory takes # about 0.15s on my machine whereas the history() method needs 5s. # Therefore we use `git log` here until dulwich gets faster. # For the pure-Python implementation, see the 'purepy-hist' branch. cmd = ["git", "log", "--format=%H"] if skip: cmd.append("--skip=%d" % skip) if max_commits: cmd.append("--max-count=%d" % max_commits) cmd.append(decode_from_git(commit.id)) if path: cmd.extend(["--", path]) output = subprocess.check_output(cmd, cwd=os.path.abspath(self.path)) sha1_sums = output.strip().split(b"\n") return [self[sha1] for sha1 in sha1_sums] @synchronized def blame(self, commit, path): """Return a 'git blame' list for the file at `path`: For each line in the file, the list contains the commit that last changed that line. """ # XXX see comment in `.history()` cmd = ["git", "blame", "-ls", "--root", decode_from_git(commit.id), "--", path] output = subprocess.check_output(cmd, cwd=os.path.abspath(self.path)) sha1_sums = [line[:40] for line in output.strip().split(b"\n") if line] return [ None if self[sha1] is None else decode_from_git(self[sha1].id) for sha1 in sha1_sums ] @synchronized def get_blob_or_tree(self, commit, path): """Return the Git tree or blob object for `path` at `commit`.""" try: (mode, oid) = tree_lookup_path( self.dulwich_repo.__getitem__, commit.tree, encode_for_git(path) ) except NotTreeError: # Some part of the path was a file where a folder was expected. # Example: path="/path/to/foo.txt" but "to" is a file in "/path". raise KeyError return self[oid] @synchronized def listdir(self, commit, path): """Return a list of submodules, directories and files in given directory: Lists of (link name, target path) tuples. """ submodules, dirs, files = [], [], [] for entry_rel in self.get_blob_or_tree(commit, path).items(): # entry_rel: Entry('foo.txt', ...) # entry_abs: Entry('spam/eggs/foo.txt', ...) entry_abs = entry_rel.in_path(encode_for_git(path)) path_str = decode_from_git(entry_abs.path) item = (os.path.basename(path_str), path_str) if S_ISGITLINK(entry_abs.mode): submodules.append(item) elif stat.S_ISDIR(entry_abs.mode): dirs.append(item) else: files.append(item) def keyfunc(tpl): return tpl[0].lower() submodules.sort(key=keyfunc) files.sort(key=keyfunc) dirs.sort(key=keyfunc) if path: dirs.insert(0, ("..", parent_directory(path))) return {"submodules": submodules, "dirs": dirs, "files": files} @synchronized def commit_diff(self, commit): """Return the list of changes introduced by `commit`.""" from klaus.utils import guess_is_binary if commit.parents: parent_tree = self[commit.parents[0]].tree else: parent_tree = None summary = {"nfiles": 0, "nadditions": 0, "ndeletions": 0} file_changes = [] # the changes in detail dulwich_changes = self.dulwich_repo.object_store.tree_changes(parent_tree, commit.tree) for (oldpath, newpath), (oldmode, newmode), (oldsha, newsha) in dulwich_changes: summary["nfiles"] += 1 try: oldblob = self.dulwich_repo.object_store[oldsha] if oldsha else Blob.from_string(b"") except KeyError: # probably related to submodules; Dulwich will handle that. oldblob = Blob.from_string(b"") try: newblob = self.dulwich_repo.object_store[newsha] if newsha else Blob.from_string(b"") except KeyError: # probably related to submodules; Dulwich will handle that. newblob = Blob.from_string(b"") # Check for binary files -- can't show diffs for these if guess_is_binary(newblob) or guess_is_binary(oldblob): file_changes.append( { "is_binary": True, "old_filename": oldpath or "/dev/null", "new_filename": newpath or "/dev/null", "chunks": None, } ) continue additions, deletions, chunks = render_diff( oldblob.splitlines(), newblob.splitlines() ) change = { "is_binary": False, "old_filename": oldpath or "/dev/null", "new_filename": newpath or "/dev/null", "chunks": chunks, "additions": additions, "deletions": deletions, } summary["nadditions"] += additions summary["ndeletions"] += deletions file_changes.append(change) return summary, file_changes @synchronized def raw_commit_diff(self, commit): if commit.parents: parent_tree = self[commit.parents[0]].tree else: parent_tree = None bytesio = io.BytesIO() dulwich.patch.write_tree_diff( bytesio, self.dulwich_repo.object_store, parent_tree, commit.tree ) return bytesio.getvalue() def freeze(self): return FrozenFancyRepo(self) class FrozenFancyRepo(object): """A special version of FancyRepo that assumes the underlying Git repository does not change. Used for performance optimizations. """ def __init__(self, repo): self.__repo = repo self.__last_updated_at = NOT_SET def __setattr__(self, name, value): if not name.startswith("_FrozenFancyRepo__"): raise TypeError("Can't set %s attribute on FrozenFancyRepo" % name) super(FrozenFancyRepo, self).__setattr__(name, value) def __getattr__(self, name): return getattr(self.__repo, name) def fast_get_last_updated_at(self): if self.__last_updated_at is NOT_SET: self.__last_updated_at = self.__repo.get_last_updated_at() return self.__last_updated_at class InvalidRepo: """Represent an invalid repository and store pertinent data.""" def __init__(self, path, namespace): self.path = path self.namespace = namespace @property def name(self): return repo_human_name(self.path) @property def namespaced_name(self): if self.namespace: return "~{}/{}".format(self.namespace, self.name) else: return self.name ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1685805302.8880074 klaus-2.0.3/klaus/static/0000755000076500000000000000000014436654367013432 5ustar00jwheel././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/static/favicon.png0000644000076500000000000000547414436654075015573 0ustar00jwheelPNG  IHDR>asBIT|d pHYsmmsH(tEXtSoftwarewww.inkscape.org< IDATxM$eO =vO/d &dj(YN9q(eA  WM%MWAvxzvSꞪ}KT̈́1fOMt"@/=1y%Yt:bADn3 n,{˓M!V98|,¡zq}Y8/yGx>%8ӥ |߿V\xԭ7V `5|sq^'}WqZX WqV}GM'%pFc̅50‡R(`nn񢫀yB%(ss/W$@%ARJ/T$5θJ(Ƙ jǚM8+ Fwvo4WI0`<߬$OvoEԩ$Gs{WJ{FQ/K`S`8`;$HNc.Z-sAg%y2*gGP<#Q/*"AxO2mc>y+l\%EbIhƷ;,t$;@jjѥ`wm ^2+r_&/#2ӿVj ]!+AfdVK(^QgD2 5j>Ziqq1UkDR @)[TuI[F$U P"aV]pGjX(rZ٨T$&@_ἙF6 FBb]+mR<`N,\$ҞWazpswh+۟CU# he|![*{/$K+#6$ׁZ\j7xƘ=+SKͅ6Zp]DSBJF0|Z10RFo-jB++{[$KDwy'3;xbbֆȔʱN&/ H[!WTbmiEv۪A䪴Due<<@=: m۶-@]\bQ}, ã@"n2;Xd*8P޸x>;Ƙ=ob727k׮!XS}Tˤ'd:o[ |7NG͉+\I+3-q&ɖi܃z4[/|X@%AbA4~c#"xNSo6{#"i6t&;;ξɅgi60WzhƩVӼ z ѳ@văh>A$P+;faAy>ĘPIP>1'TQ‡fUQÇUq‡1VqÇ1'X.>D'n,$|H0P`woŋ.Gy5%i`aNS/"E/bcM$,2<8΁gOM4Y{hY)K B qoq>qnPRlWQEoK41z? ?򒰂I[R(5z>&@vĉ;:VҢ"H j-I3|HqUpl p>|H;|HyYxX_o(H/v"|`_$P+l-ׂ‡6I8S'zY īuZVSlDC[d-mIV,< tt4499 5=("c1a>9T7MgG9l~FESm|ქSķn m?z)rrxJ-vv=&vF& mZ>@u> ?E  ChhC%(bP0M >PpK"ܐCrKP@9%(KP\)|(P >H(e J&S%%AÇ ŐC|%p!|(JR Jb v%p9|pT#@2 6Cp>l`Y CɍP}>ոt3Zv}{Td7@@^@0 cGFIENDB`././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/static/klaus.css0000644000076500000000000002252514436654075015265 0ustar00jwheel@charset "utf-8"; body, header, #content { overflow: auto; } /* Reset */ body { margin: 0; padding: 0; font-family: sans-serif; } a, a:visited { color: #003278; text-decoration: none; } a:hover { text-decoration: underline; } table { border-spacing: 0; border-collapse: collapse; } h2 > span:last-of-type { font-size: 60%; } h2 > code:last-of-type { font-size: 60%; margin-left: 2%; } .clearfloat { clear: both; } .hastooltip { cursor: help; } .separated-by-dots > span:not(:first-child):before { content: '·'; margin: 0 3px 0 5px; } .slash { color: #666; margin: 0 -0.2em; } .history ul, .repolist, .tree ul, .branch-selector ul { list-style-type: none; padding-left: 0; } /* Header */ header { font-size: 90%; padding: 0.5%; border-bottom: 3px solid #e0e0e0; } header a { padding: 0.5% 0; } header .breadcrumbs > span:before { content: ' » '; color: #666; } header .slash { margin: 0 -2px; } /* Branch/tag selector */ .branch-selector { position: absolute; top: 2px; right: 2px; font-size: 90%; background-color: #fefefe; } .branch-selector > * { background-color: #fcfcfc; position: relative; } .branch-selector > span { border: 1px solid #f1f1f1; padding: 4px 5px; float: right; } .branch-selector > span:after { content: "☟"; margin-left: 5px; } .branch-selector > span:hover { background-color: #fefefe; cursor: pointer; } .branch-selector div { z-index: 1; clear: both; display: none; } .branch-selector ul { margin: 0; } .branch-selector ul + ul { border-top: 1px solid #e0e0e0; } .branch-selector li a { display: block; padding: 4px 5px; border-bottom: 1px solid #f1f1f1; } .branch-selector li:first-child a { border-top: 1px solid #f1f1f1; } .branch-selector li a:hover { background-color: #fefefe; } .branch-selector li:last-child a { border: 0; } .branch-selector:hover { border: 1px solid #ccc; } .branch-selector:hover > span { border: 0; background-color: inherit; } .branch-selector:hover div { display: block; } /* Footer */ footer { clear: both; font-size: 80%; float: right; color: #666; padding: 50px 5px 5px 0; } footer a { color: inherit; border-bottom: 1px dotted #666; } footer a:hover { text-decoration: none; } /* Container */ #content { padding: 5px 1.5%; } #content > div:nth-of-type(1), #content > div:nth-of-type(2) { float: left; } #content > div:nth-of-type(1) { width: 24%; } #content > div:nth-of-type(2) { width: 72%; margin-left: 1.5%; } /* Pagination */ .pagination { float: right; margin: 0; font-size: 90%; } .pagination > * { border: 1px solid; padding: 2px 10px; text-align: center; } .pagination .n { font-size: 90%; padding: 1px 5px; position: relative; top: 1px; } .pagination > a { opacity: 0.6; border-color: #6491bf; } .pagination > a:hover { opacity: 1; text-decoration: none; border-color: #4D6FA0; } .pagination span { color: #999; border-color: #ccc; } /* Repo List */ .repolist { margin-left: 2em; font-size: 120%; } .repolist li { margin-bottom: 10px; } .repolist li a .last-updated { color: #737373; font-size: 60%; margin-left: 1px; } .repolist li a .description { color: black; font-size: 75%; margin-left: 1px; } .repolist li a:hover { text-decoration: none; } .repolist li a:hover .name { text-decoration: underline; } .invalid { color: red; } .invalid .reason { color: #737373; font-size: 60%; margin-left: 1px; } /* Base styles for history and commit views */ .commit { display: block; margin-bottom: 2px; padding: 8px 10px; background-color: #f9f9f9; border: 1px solid #e0e0e0; } .commit:hover { text-decoration: none; } .commit > span { display: block; } .commit .line1 { font-family: monospace; padding-bottom: 2px; line-height: 1.3; } .commit .line1 span { white-space: pre-wrap; text-overflow: hidden; } .commit:hover .line1 { text-decoration: underline; color: #aaa; } .commit:hover .line1 span { color: black; } .commit .line2 { position: relative; top: 5px; left: 1px; } .commit .line2 > span:first-child { float: left; } .commit .line2 > span:nth-child(2) { float: right; } .commit .line2 { color: #737373; font-size: 80%; } /* History View */ .history .pagination { margin-top: -2em; } a.commit { color: black !important; } .tree ul { font-family: monospace; border-top: 1px solid #e0e0e0; } .tree li { background-color: #f9f9f9; border: 1px solid #e0e0e0; border-top: 0; } .tree li a { padding: 5px 7px 6px 7px; display: block; color: #001533; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } .tree li a:before { margin-right: 5px; position: relative; top: 2px; opacity: 0.7; content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAPCAYAAADUFP50AAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sGBhMmAbS/QqsAAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAANBJREFUKM+Vkj2OgzAQhb8HSLupkKiQD8DPWbZMkSMgLsF9IlLmMpiKA9CncraIQGbXIPGqsec9faOx1TTNwxhz5YT6vr8lxphr13Wc1D1Zqnmecc4BIGl1LLUk4jgmTVMA1qBzDmvtxuhLEnVdr+fEb5ZleUj0lfgGn/hXh8SiKAKEF+/3F1EUhYkA4zhumlVVARfgBXzvjxoiSkK6/Bt9Q7TWHi7lM8HOVsNE7RMlMQxDkLRs078LEkPh3XfMsuzUZ1Xbts88z3/OhKZpuv8CNeMsq6Yg8OoAAAAASUVORK5CYII=); } .tree li a.dir:before { content: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAPCAYAAADtc08vAAAAAXNSR0IArs4c6QAAAAZiS0dEAP8A/wD/oL2nkwAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB9sGBhMiMxgE1i8AAAAdaVRYdENvbW1lbnQAAAAAAENyZWF0ZWQgd2l0aCBHSU1QZC5lBwAAAYxJREFUKM+l0r1KXEEYxvH/bNS4K2RNCjsRBAlqITESI0QCuqBdWLwACSJaiZILiDcQkDRql8oUIqkkkRTRwiKVyvoRV5HFEFEX/DjomePsmfNauB4xrLAmD1PNML95eBnV0Fj/vjPRMcpfMcYAMP9jYXDzV3qSO1LSmegYfdvbV/DQ8zS+709oVzu7u78/FwQAHOeU9Y31gsjz5hYcx5lqbXsxdb23ld4eW15aGQmBaDRGZfzxXS1JvukBQCmFUoqZL9PDIWCMQWuX76tnpLIxisqjJC39SXmoM5thg1Q2xsd3XXjGFmWUlz1g6MPc0xIArV0A9o89dg7PiwJqqyoAiHieRzRaZPUCibiuGzb4J+B6Bv8F3LeBtQFeznLrH5RGAgQQEZRSiAgiEIhgrZCzAcYXLnxLzgrxirIbQGuXmvgFR2eGP0caRBEg5BciIAgieRjwrdwAB9lDnrW9Yjlzkr909boIBAiCApGwVWvdE+a+fkOvzX5STd0D86XV7a/vOzy7t/hzaXb85SVDycBfkNNgmgAAAABJRU5ErkJggg==); } /* Blob, Blame, Diff, Markup View */ .line { display: block; } .linenos { background-color: #f9f9f9; text-align: right; } .linenos a { color: #888; } .linenos a:hover { text-decoration: none; } .highlight-line, .highlight-line .line { background-color: #fefed0; } .linenos a { padding: 0 6px 0 6px; } .markup table, .markup img, .markup pre { border: 1px solid #e0e0e0; } .markup table { min-width: 100%; } .markup img { max-width: 100%; padding: 1px; } .markup pre { padding: 10px 12px; background-color: #f9f9f9; } /* Blob, Blame View */ .blobview table, .blameview table { min-width: 100%; } .blobview table, .blameview table { border: 1px solid #e0e0e0; } .blobview .code, .blameview .code { padding: 0; width: 100%; } .blobview .code .line, .blameview .code .line { padding: 0 5px 0 10px; } .blobview .code a, .blameview .code a { color: inherit; } .blobview .linenos, .blameview .linenos { border: 1px solid #e0e0e0; padding: 0; } /* Blob View */ .blobview .markup { border: 1px solid #e0e0e0; } .blobview .markup h1:first-child { margin-top: 8px; } .blobview .markup { padding: 0 10px; } /* Blame View */ .blameview .highlighttable { border-top: 0; border-bottom: 0; border-left: 0; } .blameview .linenos { border-top: 0; border-bottom: 0; border-left: 0; } .blameview .line-info a { padding: 0 6px 0 6px; } .blameview .line-info { background-color: #f9f9f9; } /* Commit View */ .full-commit { width: 100% !important; margin-top: 10px; } .full-commit .commit { padding: 15px 20px; } .full-commit .commit .line1 { padding-bottom: 5px; } .full-commit .commit:hover .line1 { text-decoration: none; } .full-commit .commit .line2 > span { float: left; } .full-commit .summary { color: #737373; font-size: 80%; margin-top: 25px; } .full-commit .summary .additions { color: #008800; } .full-commit .summary .deletions { color: #ee4444; } .full-commit .file.collapsed > table { display: none; } .diff { font-family: monospace; } .diff .filename { padding: 8px 10px; background-color: #f9f9f9; border: 1px solid #e0e0e0; margin-top: 25px; } .diff .filename del { color: #999; } .diff .filename .summary { float: left; margin: -4px 15px 0 -5px; font-size: 80%; } .diff .filename .summary .additions { color: green; } .diff .filename .summary .deletions{ color: red; } .diff .togglers { float: right; } .diff .togglers a { opacity: 0.5; } .diff .file:not(.collapsed) .togglers .expand { display: none; } .diff .file.collapsed .togglers .collapse { display: none; } .diff table, .diff .emptydiff { border: 1px solid #e0e0e0; border-top: 0; background-color: #fdfdfd; display: block; } .diff .emptydiff { padding: 7px 10px; } .diff td { padding: 0; border-left: 1px solid #e0e0e0; } .diff td .line { padding: 1px 10px; display: block; min-height: 1.2em; white-space: pre-wrap; } .diff .linenos { font-size: 85%; padding: 0; vertical-align: top; } .diff .linenos a { display: block; padding-top: 1px; padding-bottom: 1px; } .diff td + td + td { width: 100%; } .diff tr:first-of-type td { padding-top: 7px; } .diff tr:last-of-type td { padding-bottom: 7px; } .diff table .del { background-color: #ffdddd; } .diff table .add { background-color: #ddffdd; } .diff table .no-newline-marker { font-size: 50%; margin-left: 5px; color: red; } .diff table del { background-color: #ee9999; text-decoration: none; } .diff table ins { background-color: #99ee99; text-decoration: none; } .diff .sep > td { height: 1.2em; text-align: center; background-color: #f9f9f9; border: 1px solid #e0e0e0; } .diff .sep:hover > td { background-color: #f9f9f9; } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/static/klaus.js0000644000076500000000000000357014436654075015110 0ustar00jwheelvar forEach = function(collection, func) { for(var i = 0; i < collection.length; ++i) { func(collection[i]); } } /* General collapse/expand/toggle framework. Used for hiding diffs in commits */ var toggler = { expand: function(elem) { elem.className = elem.className.replace("collapsed", ""); }, collapse: function(elem) { if (!/collapsed/.test(elem.className)) { elem.className += " collapsed"; } }, collapseAll: function(selector) { forEach(document.querySelectorAll(selector), toggler.collapse); }, expandAll: function(selector) { forEach(document.querySelectorAll(selector), toggler.expand); } }; /* Line highlighting logic for diffs */ var highlight_linenos = function(opts) { var links = document.querySelectorAll(opts.linksSelector), currentHash = location.hash; forEach(links, function(a) { var lineno = a.getAttribute('href').substr(1), associatedLine = document.getElementById(lineno); var highlight = function() { a.className = 'highlight-line'; associatedLine.className = 'highlight-line'; currentHighlight = a; } var unhighlight = function() { if (a.getAttribute('href') != location.hash) { a.className = ''; associatedLine.className = ''; } } a.onmouseover = associatedLine.onmouseover = highlight; a.onmouseout = associatedLine.onmouseout = unhighlight; // Initial highlight if (a.getAttribute('href') == location.hash) { highlight(); } }); window.onpopstate = function() { if (currentHash) { forEach(document.querySelectorAll('a[href="' + currentHash + '"]'), function(e) { e.onmouseout() }) } if (location.hash) { forEach(document.querySelectorAll('a[href="' + location.hash + '"]'), function(e) { e.onmouseover() }); currentHash = location.hash; } }; } ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/static/pygments.css0000644000076500000000000000653614436654075016020 0ustar00jwheel/* This is the Pygments Trac theme */ .code .hll { background-color: #ffffcc } .code { background: #ffffff; } .code .c { color: #999988; font-style: italic } /* Comment */ .code .err { color: #a61717; background-color: #e3d2d2 } /* Error */ .code .k { font-weight: bold } /* Keyword */ .code .o { font-weight: bold } /* Operator */ .code .cm { color: #999988; font-style: italic } /* Comment.Multiline */ .code .cp { color: #999999; font-weight: bold } /* Comment.Preproc */ .code .c1 { color: #999988; font-style: italic } /* Comment.Single */ .code .cs { color: #999999; font-weight: bold; font-style: italic } /* Comment.Special */ .code .gd { color: #000000; background-color: #ffdddd } /* Generic.Deleted */ .code .ge { font-style: italic } /* Generic.Emph */ .code .gr { color: #aa0000 } /* Generic.Error */ .code .gh { color: #999999 } /* Generic.Heading */ .code .gi { color: #000000; background-color: #ddffdd } /* Generic.Inserted */ .code .go { color: #888888 } /* Generic.Output */ .code .gp { color: #555555 } /* Generic.Prompt */ .code .gs { font-weight: bold } /* Generic.Strong */ .code .gu { color: #aaaaaa } /* Generic.Subheading */ .code .gt { color: #aa0000 } /* Generic.Traceback */ .code .kc { font-weight: bold } /* Keyword.Constant */ .code .kd { font-weight: bold } /* Keyword.Declaration */ .code .kn { font-weight: bold } /* Keyword.Namespace */ .code .kp { font-weight: bold } /* Keyword.Pseudo */ .code .kr { font-weight: bold } /* Keyword.Reserved */ .code .kt { color: #445588; font-weight: bold } /* Keyword.Type */ .code .m { color: #009999 } /* Literal.Number */ .code .s { color: #bb8844 } /* Literal.String */ .code .na { color: #008080 } /* Name.Attribute */ .code .nb { color: #999999 } /* Name.Builtin */ .code .nc { color: #445588; font-weight: bold } /* Name.Class */ .code .no { color: #008080 } /* Name.Constant */ .code .ni { color: #800080 } /* Name.Entity */ .code .ne { color: #990000; font-weight: bold } /* Name.Exception */ .code .nf { color: #990000; font-weight: bold } /* Name.Function */ .code .nn { color: #555555 } /* Name.Namespace */ .code .nt { color: #000080 } /* Name.Tag */ .code .nv { color: #008080 } /* Name.Variable */ .code .ow { font-weight: bold } /* Operator.Word */ .code .w { color: #bbbbbb } /* Text.Whitespace */ .code .mf { color: #009999 } /* Literal.Number.Float */ .code .mh { color: #009999 } /* Literal.Number.Hex */ .code .mi { color: #009999 } /* Literal.Number.Integer */ .code .mo { color: #009999 } /* Literal.Number.Oct */ .code .sb { color: #bb8844 } /* Literal.String.Backtick */ .code .sc { color: #bb8844 } /* Literal.String.Char */ .code .sd { color: #bb8844 } /* Literal.String.Doc */ .code .s2 { color: #bb8844 } /* Literal.String.Double */ .code .se { color: #bb8844 } /* Literal.String.Escape */ .code .sh { color: #bb8844 } /* Literal.String.Heredoc */ .code .si { color: #bb8844 } /* Literal.String.Interpol */ .code .sx { color: #bb8844 } /* Literal.String.Other */ .code .sr { color: #808000 } /* Literal.String.Regex */ .code .s1 { color: #bb8844 } /* Literal.String.Single */ .code .ss { color: #bb8844 } /* Literal.String.Symbol */ .code .bp { color: #999999 } /* Name.Builtin.Pseudo */ .code .vc { color: #008080 } /* Name.Variable.Class */ .code .vg { color: #008080 } /* Name.Variable.Global */ .code .vi { color: #008080 } /* Name.Variable.Instance */ .code .il { color: #009999 } /* Literal.Number.Integer.Long */ ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/static/robots.txt0000644000076500000000000000031114436654075015472 0ustar00jwheelUser-agent: * Allow: /*/blob/master/ Allow: /*/tree/master/ Allow: /*/raw/master/ Disallow: /*/tree/ Disallow: /*/blob/ Disallow: /*/raw/ Disallow: /*/blame/ Disallow: /*/commit/ Disallow: /*/tarball/ ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1685805302.8889575 klaus-2.0.3/klaus/templates/0000755000076500000000000000000014436654367014141 5ustar00jwheel././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/base.html0000644000076500000000000000252214436654075015736 0ustar00jwheel{% extends 'skeleton.html' %} {% block title %} {{ repo.name }} ({{ rev|shorten_sha1 }}) {% endblock %} {% block breadcrumbs %} {{ repo.namespaced_name }} / {{ rev|shorten_sha1 }} {% if subpaths %} {% for name, subpath in subpaths %} {% if loop.last %} {{ name|force_unicode }} {% else %} {{ name|force_unicode }} / {% endif %} {% endfor %} {% endif %} {% endblock %} {% block extra_header %}
{{ rev|shorten_sha1 }}
{% if tags %}
    {% for tag in tags %}
  • {{ tag }}
  • {% endfor %}
{% endif %}
{% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/blame_blob.html0000644000076500000000000000234614436654075017106 0ustar00jwheel{% extends 'base.html' %} {% block title %} {{ path }} - {{ super() }} {% endblock %} {% block content %} {% include 'tree.inc.html' %}

{{ filename|force_unicode }} @{{ rev|shorten_sha1 }}

{% if not can_render %} (Can't show blame: File is binary or too large) {% else %}
              {%- for commit in line_commits -%}
                {%- if commit == None %}
 
                {%- else %}
{{ commit | shorten_sha1 }}
                {%- endif -%}
              {%- endfor -%}
            
{% autoescape false %} {{ rendered_code }} {% endautoescape %}
{% endif %}
{% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/history.html0000644000076500000000000000070014436654075016521 0ustar00jwheel{% extends 'base.html' %} {% block title %} History of {% if path %}{{ path }} - {% endif %} {{ super() }} {% endblock %} {% block content %} {% include 'tree.inc.html' %}
{% if rendered_code %} {% autoescape false %} {% if is_markup %}
{{ rendered_code }}
{% else %} {{ rendered_code }} {% endif %} {% endautoescape %}
{% endif %} {% include 'history.inc.html' %}
{% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/history.inc.html0000644000076500000000000000414214436654075017275 0ustar00jwheel{% macro pagination() %}
{% endmacro %}

{% if subpaths %} History of {% for name, subpath in subpaths %} {{ name }} {% if not loop.last %} / {% endif %} {% endfor %} {% else %} Commit History {% endif %} @{{ rev }} {% if USE_SMARTHTTP %} git clone {{ url_for('index', repo=repo.name, namespace=namespace, _external=True) }} {% endif %} {% if repo.cloneurl %} git clone {{ repo.cloneurl }} {% endif %}

{{ pagination() }}
{{ pagination() }} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/index.html0000644000076500000000000000061314436654075016132 0ustar00jwheel{% extends 'base.html' %} {% block title %} {{ super() }} {% endblock %} {% block content %} {% include 'tree.inc.html' %}
{% if rendered_code %} {% autoescape false %} {% if is_markup %}
{{ rendered_code }}
{% else %} {{ rendered_code }} {% endif %} {% endautoescape %}
{% endif %} {% include 'history.inc.html' %}
{% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/repo_list.html0000644000076500000000000000353714436654075017033 0ustar00jwheel{% extends 'skeleton.html' %} {% block title %}Repository list{% endblock %} {% block content %}

Repositories

Order by:

{% if invalid_repos %}
    {% for repo in invalid_repos %}
  • {{ repo.namespaced_name }}
    Invalid git repository
  • {% endfor %}
{% endif%} {% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/skeleton.html0000644000076500000000000000166114436654075016653 0ustar00jwheel {% if base_href %} {% endif %} {% block title %}{% endblock %} - {{ SITE_NAME }}
{{ SITE_NAME }} {% block breadcrumbs %}{% endblock %} {% block extra_header %}{% endblock %}
{% block content %}{% endblock %}
powered by klaus {{ KLAUS_VERSION }}, a simple Git viewer by Jonas Haag
././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/submodule.html0000644000076500000000000000076714436654075017034 0ustar00jwheel{% extends 'base.html' %} {% block title %} {{ path }} - {{ super() }} {% endblock %} {% block content %}

{{ path|force_unicode }} @{{ rev|shorten_sha1 }}

The path at {{ path|force_unicode }} contains a submodule, revision {{ submodule_rev }}. {% if submodule_url %} It can be checked out from {{ submodule_url }}. {% endif %}

{% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/tree.inc.html0000644000076500000000000000156414436654075016540 0ustar00jwheel

Tree @{{ rev|shorten_sha1 }} (Download .tar.gz)

    {% for name, fullpath in root_tree.dirs %}
  • {{ name }}
  • {% endfor %} {% for name, fullpath in root_tree.submodules %}
  • {{ name }}
  • {% endfor %} {% for name, fullpath in root_tree.files %}
  • {{ name }}
  • {% endfor %}
././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/view_blob.html0000644000076500000000000000327214436654075016777 0ustar00jwheel{% extends 'base.html' %} {% block title %} {{ path }} - {{ super() }} {% endblock %} {% block content %} {% include 'tree.inc.html' %} {% set raw_url = url_for('raw', repo=repo.name, namespace=namespace, rev=rev, path=path) %} {% macro not_shown(reason) %}
({{ reason }} not shown — Download file)
{% endmacro %}

{{ filename|force_unicode }} @{{ rev|shorten_sha1 }} — {% if is_markup %} {% if render_markup %} view markup {% else %} view rendered {% endif %} · {% endif %} raw · history {% if not is_binary and not too_large %} · blame {% endif %}

{% if is_binary %} {% if is_image %} {% else %} {{ not_shown("Binary data") }} {% endif %} {% elif too_large %} {{ not_shown("Large file") }} {% else %} {% autoescape false %} {% if is_markup and render_markup %}
{{ rendered_code }}
{% else %} {{ rendered_code }} {% endif %} {% endautoescape %} {% endif %}
{% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/templates/view_commit.html0000644000076500000000000001351614436654075017353 0ustar00jwheel{% extends 'base.html' %} {% block extra_header %}{% endblock %} {# hide branch selector #} {% block title %} Commit {{ rev }} - {{ repo.name }} {% endblock %} {% block content %} {% set summary, file_changes = repo.commit_diff(commit) %}
{{ commit.message|force_unicode }} {% if commit.author != commit.committer %} {{ commit.author|force_unicode|extract_author_name }} authored {{ commit.author_time|timesince }} {{ commit.committer|force_unicode|extract_author_name }} committed {{ commit.commit_time|timesince }} {% else %} {{ commit.committer|force_unicode|extract_author_name }} {{ commit.commit_time|timesince }} {% endif %}
{{ summary.nfiles }} changed file(s) with {{ summary.nadditions }} addition(s) and {{ summary.ndeletions }} deletion(s). Raw diff Collapse all Expand all
{% for file in file_changes %}
{% set fileno = loop.index0 %}
{% if not file.get('is_binary') %}
+{{ file.additions }}
-{{ file.deletions }}
{% endif %} {# TODO dulwich doesn't do rename recognition {% if file.old_filename != file.new_filename %} {{ file.old_filename }} → {% endif %}#} {% if file.new_filename == '/dev/null' %} {{ file.old_filename|force_unicode }} {% else %} {{ file.new_filename|force_unicode }} {% endif %} less more
{% if file.get('is_binary') %}
Binary diff not shown
{% else %} {% for chunk in file.chunks %} {%- for line in chunk -%} {#- left column: linenos -#} {%- if line.old_lineno is not none -%} {%- if line.new_lineno is not none -%} {%- else -%} {%- endif -%} {%- else %} {% endif %} {#- right column: code -#} {%- if line.old_lineno -%} {%- set line_id = "%s-L-%s"|format(fileno, line.old_lineno) -%} {%- else -%} {%- set line_id = "%s-R-%s"|format(fileno, line.new_lineno) -%} {%- endif -%} {%- endfor -%} {# lines #} {% if not loop.last %} {% endif %} {% else %} {% if file.old_filename == '/dev/null' %}
(New empty file)
{% elif file.new_filename == '/dev/null' %}
(Empty file)
{% else %} {# This case happens if a file has undergone only mode changes. In the future, if we have rename recognition, it may also happen if the file has been renamed without having its content changed. Currently, renames are always reported by dulwich as a file deletion and addition. #}
(No changes)
{% endif %} {%- endfor -%} {# chunks #}
{{ line.old_lineno }}{{ line.new_lineno }} {{ line.new_lineno }} {#- lineno anchors -#} {#- the actual line of code -#} {% autoescape false %}{{ line.line|force_unicode }}{% endautoescape %}{% if line.no_newline %}{% endif %}
{% endif %}
{% endfor %}
{% endblock %} ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/utils.py0000644000076500000000000002111314436654075013647 0ustar00jwheel# encoding: utf-8 import binascii import os import re import time import datetime import mimetypes import locale import warnings import subprocess try: import chardet except ImportError: chardet = None from werkzeug.middleware.proxy_fix import ProxyFix as WerkzeugProxyFix from humanize import naturaltime class ProxyFix(WerkzeugProxyFix): """This middleware can be applied to add HTTP (reverse) proxy support to a WSGI application (klaus), making it possible to: * Mount it under a sub-URL (http://example.com/git/...) * Use a different HTTP scheme (HTTP vs. HTTPS) * Make it appear under a different domain altogether It sets `REMOTE_ADDR`, `HTTP_HOST` and `wsgi.url_scheme` from `X-Forwarded-*` headers. It also sets `SCRIPT_NAME` from the `X-Script-Name` header. For instance if you have klaus mounted under /git/ and your site uses SSL (but your proxy doesn't), make the proxy pass :: X-Script-Name = '/git' X-Forwarded-Proto = 'https' ... If you have more than one proxy server in front of your app, set `num_proxies` accordingly. Do not use this middleware in non-proxy setups for security reasons. The original values of `REMOTE_ADDR` and `HTTP_HOST` are stored in the WSGI environment as `werkzeug.proxy_fix.orig_remote_addr` and `werkzeug.proxy_fix.orig_http_host`. :param app: the WSGI application :param num_proxies: the number of proxy servers in front of the app. """ def __call__(self, environ, start_response): script_name = environ.get("HTTP_X_SCRIPT_NAME") if script_name is not None: if script_name.endswith("/"): warnings.warn( "'X-Script-Name' header should not end in '/' (found: %r). " "Please fix your proxy's configuration." % script_name ) script_name = script_name.rstrip("/") environ["SCRIPT_NAME"] = script_name return super(ProxyFix, self).__call__(environ, start_response) class SubUri(object): """WSGI middleware to tweak the WSGI environ so that it's possible to serve the wrapped app (klaus) under a sub-URL and/or to use a different HTTP scheme (http:// vs. https://) for proxy communication. This is done by making your proxy pass appropriate HTTP_X_SCRIPT_NAME and HTTP_X_SCHEME headers. For instance if you have klaus mounted under /git/ and your site uses SSL (but your proxy doesn't), make it pass :: X-Script-Name = '/git' X-Scheme = 'https' Snippet stolen from http://flask.pocoo.org/snippets/35/ """ def __init__(self, app): warnings.warn( "'klaus.utils.SubUri' is deprecated and will be removed. " "Please upgrade your code to use 'klaus.utils.ProxyFix' instead.", DeprecationWarning, ) self.app = app def __call__(self, environ, start_response): script_name = environ.get("HTTP_X_SCRIPT_NAME", "") if script_name: environ["SCRIPT_NAME"] = script_name.rstrip("/") if script_name and environ["PATH_INFO"].startswith(script_name): # strip `script_name` from PATH_INFO environ["PATH_INFO"] = environ["PATH_INFO"][len(script_name) :] if "HTTP_X_SCHEME" in environ: environ["wsgi.url_scheme"] = environ["HTTP_X_SCHEME"] return self.app(environ, start_response) def timesince(when, now=time.time): """Return the difference between `when` and `now` in human readable form.""" return naturaltime(now() - when) def formattimestamp(timestamp): return datetime.datetime.fromtimestamp(timestamp).strftime("%b %d, %Y %H:%M:%S") def guess_is_binary(dulwich_blob): return any(b"\0" in chunk for chunk in dulwich_blob.chunked) def guess_is_image(filename): mime, _ = mimetypes.guess_type(filename) if mime is None: return False return mime.startswith("image/") def encode_for_git(s): # XXX This assumes everything to be UTF-8 encoded return s.encode("utf8") def decode_from_git(b): # XXX This assumes everything to be UTF-8 encoded return b.decode("utf8") def force_unicode(s): """Do all kinds of magic to turn `s` into unicode""" # It's already unicode, don't do anything: try: # Python < 3 text_type = unicode except NameError: text_type = str if isinstance(s, text_type): return s # Try some default encodings: try: return s.decode("utf-8") except UnicodeDecodeError: pass try: return s.decode(locale.getpreferredencoding()) except UnicodeDecodeError: pass if chardet is not None: # Try chardet, if available encoding = chardet.detect(s)["encoding"] if encoding is not None: try: return s.decode(encoding, 'replace') except LookupError: pass return s.decode('latin1', 'replace') def extract_author_name(email): """Extract the name from an email address -- >>> extract_author_name("John ") "John" -- or return the address if none is given. >>> extract_author_name("noname@example.com") "noname@example.com" """ match = re.match("^(.*?)<.*?>$", email) if match: return match.group(1).strip() return email def is_hex_prefix(s): if len(s) % 2: s += "0" try: binascii.unhexlify(s) return True except binascii.Error: return False def shorten_sha1(sha1): if 20 <= len(sha1) <= 40 and is_hex_prefix(sha1): sha1 = sha1[:7] return sha1 def parent_directory(path): return os.path.split(path)[0] def subpaths(path): """Yield a `(last part, subpath)` tuple for all possible sub-paths of `path`. >>> list(subpaths("foo/bar/spam")) [('foo', 'foo'), ('bar', 'foo/bar'), ('spam', 'foo/bar/spam')] """ seen = [] for part in path.split("/"): seen.append(part) yield part, "/".join(seen) def shorten_message(msg): return msg.split("\n")[0] def replace_dupes(ls, replacement): """Replace items in `ls` that are equal to their predecessors with `replacement`. >>> ls = [1, 2, 2, 3, 2, 2, 2] >>> replace_dupes(x, 'x') >>> ls [1, 2, 'x', 3, 2, 'x', 'x'] """ last = object() for i, elem in enumerate(ls): if last == elem: ls[i] = replacement else: last = elem def guess_git_revision(): """Try to guess whether this instance of klaus is run directly from a klaus git checkout. If it is, guess and return the currently checked-out commit SHA. If it's not (installed using pip, setup.py or the like), return None. This is used to display the "powered by klaus $VERSION" footer on each page, $VERSION being either the SHA guessed by this function or the latest release number. """ git_dir = os.path.join(os.path.dirname(__file__), "..", ".git") try: return force_unicode( subprocess.check_output( ["git", "log", "--format=%h", "-n", "1"], cwd=git_dir ).strip() ) except OSError: # Either the git executable couldn't be found in the OS's PATH # or no ".git" directory exists, i.e. this is no "bleeding-edge" installation. return None def sanitize_branch_name(name, chars="./", repl="-"): for char in chars: name = name.replace(char, repl) return name def escape_html(s): return ( s.replace(b"&", b"&") .replace(b"<", b"<") .replace(b">", b">") .replace(b'"', b""") ) def tarball_basename(repo_name, rev): """Determine the name for a tarball.""" rev = sanitize_branch_name(rev, chars="/") if rev.startswith(repo_name + "-"): # If the rev is a tag name that already starts with the repo name, # skip it. return rev elif len(rev) >= 2 and rev[0] == "v" and not rev[1].isalpha(): # If the rev is a tag name prefixed by a 'v', skip the 'v'. # So, v-1.0 -> 1.0, v1.0 -> 1.0, but vanilla -> vanilla. return "%s-%s" % (repo_name, rev[1:]) elif len(rev) == 40 and is_hex_prefix(rev): # If the rev is a commit hash, simply use that. return "%s@%s" % (repo_name, rev) else: return "%s-%s" % (repo_name, rev) def repo_human_name(path): """Get repository name from path. 1. /x/y.git -> /x/y and /x/y/.git/ -> /x/y// 2. /x/y/ -> /x/y 3. /x/y -> y """ name = path.rstrip(os.sep).split(os.sep)[-1] if name.endswith(".git"): name = name[:-4] return name ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus/views.py0000644000076500000000000004333114436654075013652 0ustar00jwheelfrom io import BytesIO import os import sys from flask import request, render_template, current_app, url_for from flask.views import View from werkzeug.wrappers import Response from werkzeug.exceptions import NotFound import dulwich.objects import dulwich.archive import dulwich.config from dulwich.object_store import tree_lookup_path try: from dulwich.refs import SymrefLoop except ImportError: # dulwich < 0.20.46 class SymrefLoop(Exception): """Dummy exception.""" try: import ctags except ImportError: ctags = None else: from klaus import ctagscache CTAGS_CACHE = ctagscache.CTagsCache() from klaus import markup from klaus.highlighting import highlight_or_render from klaus.utils import ( parent_directory, subpaths, force_unicode, guess_is_binary, guess_is_image, replace_dupes, sanitize_branch_name, encode_for_git, ) README_FILENAMES = [ b"README", b"README.md", b"README.mkdn", b"README.mdwn", b"README.markdown", b"README.rst", ] def repo_list(): """Show a list of all repos. Can be sorted by last update and repo names can be searched.""" repos = [repo.freeze() for repo in current_app.valid_repos.values()] invalid_repos = current_app.invalid_repos.values() order_by = request.args.get("order_by") or "last_updated" search_query = request.args.get("q") or "" if search_query: repos = [r for r in repos if search_query.lower() in r.namespaced_name.lower()] invalid_repos = [ r for r in invalid_repos if search_query.lower() in r.namespaced_name.lower() ] if order_by == "name": def sort_key(repo): return repo.namespaced_name else: def sort_key(repo): return -(repo.fast_get_last_updated_at() or -1), repo.namespaced_name repos = sorted(repos, key=sort_key) invalid_repos = sorted(invalid_repos, key=lambda repo: repo.namespaced_name) return render_template( "repo_list.html", repos=repos, invalid_repos=invalid_repos, order_by=order_by, search_query=search_query, base_href=None, ) def robots_txt(): """Serve the robots.txt file to manage the indexing of the site by search engines.""" return current_app.send_static_file("robots.txt") def _get_repo_and_rev(repo, namespace=None, rev=None, path=None): if path and rev: rev += "/" + path.rstrip("/") if namespace: repo_key = "~{}/{}".format(namespace, repo) else: repo_key = repo try: repo = current_app.valid_repos[repo_key] except KeyError: raise NotFound("No such repository %r" % repo) if rev is None: rev = repo.get_default_branch() if rev is None: rev = "HEAD" try: repo.get_commit("HEAD") except KeyError: raise NotFound("No commits yet") i = len(rev) while i > 0: try: commit = repo.get_commit(rev[:i]) path = rev[i:].strip("/") rev = rev[:i] except (KeyError, IOError): i = rev.rfind("/", 0, i) except SymrefLoop as e: raise NotFound( "symref loop for %s at depth %d" % (e.ref, e.depth)) else: break else: raise NotFound("No such commit %r" % rev) return repo, rev, path, commit def _get_submodule(repo, commit, path): """Retrieve submodule URL and path.""" submodule_blob = repo.get_blob_or_tree(commit, ".gitmodules") config = dulwich.config.ConfigFile.from_file( BytesIO(submodule_blob.as_raw_string()) ) key = (b"submodule", path) submodule_url = config.get(key, b"url") submodule_path = config.get(key, b"path") return (submodule_url, submodule_path) class BaseRepoView(View): """Base for all views with a repo context. The arguments `repo`, `rev`, `path` (see `dispatch_request`) define the repository, branch/commit and directory/file context, respectively -- that is, they specify what (and in what state) is being displayed in all the derived views. For example: The 'history' view is the `git log` equivalent, i.e. if `path` is "/foo/bar", only commits related to "/foo/bar" are displayed, and if `rev` is "master", the history of the "master" branch is displayed. """ def __init__(self, view_name): self.view_name = view_name self.context = {} def dispatch_request(self, repo, namespace=None, rev=None, path=""): """Dispatch repository, revision (if any) and path (if any). To retain compatibility with :func:`url_for`, view routing uses two arguments: rev and path, although a single path is sufficient (from Git's point of view, '/foo/bar/baz' may be a branch '/foo/bar' containing baz, or a branch '/foo' containing 'bar/baz', but never both [1]. Hence, rebuild rev and path to a single path argument, which is then later split into rev and path again, but revision now may contain slashes. [1] https://github.com/jonashaag/klaus/issues/36#issuecomment-23990266 """ self.make_template_context(repo, namespace, rev, path.strip("/")) return self.get_response() def get_response(self): return render_template(self.template_name, **self.context) def make_template_context(self, repo, namespace, rev, path): repo, rev, path, commit = _get_repo_and_rev(repo, namespace, rev, path) try: blob_or_tree = repo.get_blob_or_tree(commit, path) except KeyError: raise NotFound("File not found") self.context = { "view": self.view_name, "repo": repo, "namespace": namespace, "rev": rev, "commit": commit, "branches": repo.get_branch_names(exclude=rev), "tags": repo.get_tag_names(), "path": path, "blob_or_tree": blob_or_tree, "subpaths": list(subpaths(path)) if path else None, "base_href": None, } class CommitView(BaseRepoView): template_name = "view_commit.html" class PatchView(BaseRepoView): def get_response(self): return Response( self.context["repo"].raw_commit_diff(self.context["commit"]), mimetype="text/plain", ) class TreeViewMixin(object): """The logic required for displaying the current directory in the sidebar.""" def make_template_context(self, *args): super(TreeViewMixin, self).make_template_context(*args) self.context["root_tree"] = self.listdir() def listdir(self): """Return a list of directories and files in the current path of the selected commit.""" root_directory = self.get_root_directory() return self.context["repo"].listdir(self.context["commit"], root_directory) def get_root_directory(self): root_directory = self.context["path"] if isinstance(self.context["blob_or_tree"], dulwich.objects.Blob): # 'path' is a file (not folder) name root_directory = parent_directory(root_directory) return root_directory class ReadmeMixin(object): """The logic required for finding and displaying README files.""" def _get_readme(self): commit, path = self.context["commit"], self.context["path"] tree = self.context["repo"].get_blob_or_tree(commit, path) if not isinstance(tree, dulwich.objects.Tree): raise KeyError for name in README_FILENAMES: if name.lower() in [t.lower() for t in tree]: obj = self.context["repo"][tree[name][1]] if obj.type_name == b'blob': readme_data = obj.data readme_filename = name return (readme_filename, readme_data) else: raise KeyError def get_readme_context(self): try: (readme_filename, readme_data) = self._get_readme() except KeyError: return { "is_markup": None, "rendered_code": None, } else: readme_filename = force_unicode(readme_filename) readme_data = force_unicode(readme_data) return { "is_markup": markup.can_render(readme_filename), "rendered_code": highlight_or_render(readme_data, readme_filename), } class HistoryView(TreeViewMixin, ReadmeMixin, BaseRepoView): """Show commits of a branch + path, just like `git log`. With pagination. Also, README, if available.""" template_name = "history.html" def make_template_context(self, *args): super(HistoryView, self).make_template_context(*args) try: page = int(request.args.get("page")) except (TypeError, ValueError): page = 0 self.context["page"] = page history_length = 30 if page: skip = (self.context["page"] - 1) * history_length + 10 if page > 7: self.context["previous_pages"] = [0, 1, 2, None] + list(range(page))[ -3: ] else: self.context["previous_pages"] = range(page) else: skip = 0 history = self.context["repo"].history( self.context["commit"], self.context["path"], history_length + 1, skip ) if len(history) == history_length + 1: # At least one more commit for next page left more_commits = True # We don't want show the additional commit on this page history.pop() else: more_commits = False self.context.update( { "history": history, "more_commits": more_commits, } ) self.context.update(self.get_readme_context()) class IndexView(TreeViewMixin, ReadmeMixin, BaseRepoView): """Show commits of a branch, just like `git log`. Also, README, if available.""" template_name = "index.html" def make_template_context(self, *args): super(IndexView, self).make_template_context(*args) self.context["base_href"] = url_for( "blob", repo=self.context["repo"].namespaced_name, rev=self.context["rev"], path="", ) self.context["page"] = 0 history_length = 10 history = self.context["repo"].history( self.context["commit"], self.context["path"], history_length + 1, skip=0, ) if len(history) == history_length + 1: # At least one more commit for next page left more_commits = True # We don't want show the additional commit on this page history.pop() else: more_commits = False self.context.update( { "history": history, "more_commits": more_commits, } ) self.context.update(self.get_readme_context()) class BaseBlobView(BaseRepoView): def make_template_context(self, *args): super(BaseBlobView, self).make_template_context(*args) if not isinstance(self.context["blob_or_tree"], dulwich.objects.Blob): raise NotFound("Not a blob") self.context["filename"] = os.path.basename(self.context["path"]) class SubmoduleView(BaseRepoView): """Show an information page about a submodule.""" template_name = "submodule.html" def make_template_context(self, repo, namespace, rev, path): repo, rev, path, commit = _get_repo_and_rev(repo, namespace, rev, path) try: submodule_rev = tree_lookup_path( repo.__getitem__, commit.tree, encode_for_git(path) )[1] except KeyError: raise NotFound("Parent path for submodule missing") try: (submodule_url, submodule_path) = _get_submodule( repo, commit, encode_for_git(path) ) except KeyError: submodule_url = None submodule_path = None # TODO(jelmer): Rather than printing an information page, # redirect to the page in klaus for the repository at # submodule_path, revision submodule_rev. self.context = { "view": self.view_name, "repo": repo, "rev": rev, "commit": commit, "branches": repo.get_branch_names(exclude=rev), "tags": repo.get_tag_names(), "path": path, "subpaths": list(subpaths(path)) if path else None, "submodule_url": force_unicode(submodule_url), "submodule_path": force_unicode(submodule_path), "submodule_rev": force_unicode(submodule_rev), "base_href": None, } class BaseFileView(TreeViewMixin, BaseBlobView): """Base for FileView and BlameView.""" def render_code(self, render_markup): should_use_ctags = current_app.should_use_ctags( self.context["repo"], self.context["commit"] ) if should_use_ctags: if ctags is None: raise ImportError("Ctags enabled but python-ctags not installed") ctags_base_url = url_for( self.view_name, repo=self.context["repo"].namespaced_name, rev=self.context["rev"], path="", ) ctags_tagsfile = CTAGS_CACHE.get_tagsfile( self.context["repo"].path, self.context["commit"].id ) ctags_args = { "ctags": ctags.CTags( ctags_tagsfile.encode(sys.getfilesystemencoding()) ), "ctags_baseurl": ctags_base_url, } else: ctags_args = {} return highlight_or_render( force_unicode(self.context["blob_or_tree"].data), self.context["filename"], render_markup, **ctags_args ) def make_template_context(self, *args): super(BaseFileView, self).make_template_context(*args) self.context.update( { "can_render": True, "is_binary": False, "too_large": False, "is_markup": False, } ) binary = guess_is_binary(self.context["blob_or_tree"]) too_large = sum(map(len, self.context["blob_or_tree"].chunked)) > 100 * 1024 if binary: self.context.update( { "can_render": False, "is_binary": True, "is_image": guess_is_image(self.context["filename"]), } ) elif too_large: self.context.update( { "can_render": False, "too_large": True, } ) class FileView(BaseFileView): """Shows a file rendered using ``pygmentize``.""" template_name = "view_blob.html" def make_template_context(self, *args): super(FileView, self).make_template_context(*args) if self.context["can_render"]: render_markup = "markup" not in request.args self.context.update( { "is_markup": markup.can_render(self.context["filename"]), "render_markup": render_markup, "rendered_code": self.render_code(render_markup), } ) class BlameView(BaseFileView): template_name = "blame_blob.html" def make_template_context(self, *args): super(BlameView, self).make_template_context(*args) if self.context["can_render"]: line_commits = self.context["repo"].blame( self.context["commit"], self.context["path"] ) replace_dupes(line_commits, None) self.context.update( { "rendered_code": self.render_code(render_markup=False), "line_commits": line_commits, } ) class RawView(BaseBlobView): """Show a single file in raw for (as if it were a normal filesystem file served through a static file server). """ def get_response(self): # Explicitly set an empty mimetype. This should work well for most # browsers as they do file type recognition anyway. # The correct way would be to implement proper file type recognition here. return Response(self.context["blob_or_tree"].chunked, mimetype="") class DownloadView(BaseRepoView): """Download a repo as a tar.gz file.""" def get_response(self): basename = "%s@%s" % ( self.context["repo"].name, sanitize_branch_name(self.context["rev"]), ) tarname = basename + ".tar.gz" headers = { "Content-Disposition": "attachment; filename=%s" % tarname, "Cache-Control": "no-store", # Disables browser caching } tar_stream = dulwich.archive.tar_stream( self.context["repo"], self.context["blob_or_tree"], self.context["commit"].commit_time, format="gz", prefix=encode_for_git(basename), ) return Response(tar_stream, mimetype="application/x-tgz", headers=headers) history = HistoryView.as_view("history", "history") index = IndexView.as_view("index", "index") commit = CommitView.as_view("commit", "commit") patch = PatchView.as_view("patch", "patch") blame = BlameView.as_view("blame", "blame") blob = FileView.as_view("blob", "blob") raw = RawView.as_view("raw", "raw") download = DownloadView.as_view("download", "download") submodule = SubmoduleView.as_view("submodule", "submodule") ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/klaus.10000644000076500000000000000263314436654075012225 0ustar00jwheel.TH KLAUS "1" "December 2015" "klaus 4e82832" "User Commands" .SH NAME klaus \- easy to set up Git web viewer .SH SYNOPSIS .B klaus [\fIOPTION\fR]... [\fIDIR\fR]... .SH DESCRIPTION Klaus is a simple and easy-to-set-up Git web viewer that Just Works\(tm. .PP Note that the klaus binary just starts a test instance. The klaus script uses wsgiref internally which doesn't scale at all - it's single-threaded and non-asynchronous. .PP It supports syntax highlighting and Git Smart HTTP. .SH OPTIONS .TP \fB\-h\fR, \fB\-\-help\fR show this help message and exit .TP \fB\-\-host\fR HOST default: 127.0.0.1 .TP \fB\-\-port\fR PORT default: 8080 .TP \fB\-\-site\-name\fR SITE_NAME site name showed in header. default: your hostname .TP \fB\-\-version\fR print version number .TP \fB\-b\fR, \fB\-\-browser\fR open klaus in a browser on server start .TP \fB\-B\fR BROWSER, \fB\-\-with\-browser\fR BROWSER specify which browser to use with \fB\-\-browser\fR .TP \fB\-\-ctags\fR {none,tags\-and\-branches,ALL} enable ctags for which revisions? default: none. WARNING: Don't use 'ALL' for public servers! .SS "Git Smart HTTP:" .TP \fB\-\-smarthttp\fR enable Git Smart HTTP serving .TP \fB\-\-htdigest\fR FILE use credentials from FILE .SS "Development flags:" .IP DO NOT USE IN PRODUCTION! .TP \fB\-\-debug\fR Enable Werkzeug debugger and reloader .SH AUTHORS Copyright \(co 2011-2015 Jonas Haag and contributors (see Git logs). ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1685805302.8871253 klaus-2.0.3/klaus.egg-info/0000755000076500000000000000000014436654367013635 5ustar00jwheel././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805302.0 klaus-2.0.3/klaus.egg-info/PKG-INFO0000644000076500000000000001144614436654366014737 0ustar00jwheelMetadata-Version: 2.1 Name: klaus Version: 2.0.3 Summary: The first Git web viewer that Just Works™. Home-page: https://github.com/jonashaag/klaus Author: Jonas Haag Author-email: jonas@lophus.org Classifier: Development Status :: 5 - Production/Stable Classifier: Topic :: Internet :: WWW/HTTP :: Dynamic Content Classifier: Topic :: Internet :: WWW/HTTP :: WSGI :: Application Classifier: Topic :: Software Development :: Version Control Classifier: Environment :: Web Environment Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: ISC License (ISCL) Classifier: Programming Language :: Python Classifier: Programming Language :: Python :: 2.7 Classifier: Programming Language :: Python :: 3 License-File: LICENSE |travis-badge| |gitter-badge| .. |travis-badge| image:: https://travis-ci.org/jonashaag/klaus.svg?branch=master :target: https://travis-ci.org/jonashaag/klaus .. |gitter-badge| image:: https://badges.gitter.im/Join%20Chat.svg :alt: Join the chat at https://gitter.im/jonashaag/klaus :target: https://gitter.im/jonashaag/klaus?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge klaus: a simple, easy-to-set-up Git web viewer that Just Works™. ================================================================ (If it doesn't Just Work for you, please file a bug.) * Super easy to set up -- no configuration required * Syntax highlighting * Markdown + RestructuredText rendering support * Pull + push support (Git Smart HTTP) * Code navigation using Exuberant ctags :Demo: http://klausdemo.lophus.org :Mailing list: http://groups.google.com/group/klaus-users :On PyPI: http://pypi.python.org/pypi/klaus/ :Wiki: https://github.com/jonashaag/klaus/wiki :License: ISC (BSD) Running with Docker -------------------- The easiest way to get started. We maintain a Docker image that has syntax highlighting, Markdown rendering, code navigation, etc. pre-configured:: docker run -v /path/to/your/repos:/repos \ -p 7777:80 \ -it jonashaag/klaus:latest \ klaus --host 0.0.0.0 --port 80 /repos/repo1 /repos/repo2 ... (Replace ``/path/to/your/repos`` with the folder that contains your Git repositories on the Docker host. You can also pass in multiple ``-v`` arguments if your repos are in multiple folders on the host.) Go to http://localhost:7777 on the Docker host et voilà! The command line above simply runs the ``klaus`` script -- for usage details, see the "Using the ``klaus`` script" section below. Local setup ----------- :: pip install klaus (Optional dependencies: see `Markup rendering `_ in the wiki.) Usage ----- See also: `Klaus wiki `_ Using the ``klaus`` script ^^^^^^^^^^^^^^^^^^^^^^^^^^ **NOTE:** This is intended for testing/low-traffic local installations *only*! The `klaus` script uses wsgiref_ internally which doesn't scale *at all* (in fact it's single-threaded and non-asynchronous). To run klaus using the default options: .. code-block:: bash # With Docker: docker run ... jonashaag/klaus:latest klaus [repo1 [repo2 ...]] # Local setup: klaus [repo1 [repo2 ...]] For more options, see: .. code-block:: bash # With Docker: docker run ... jonashaag/klaus:latest klaus --help # Local setup: klaus --help Using a real server ^^^^^^^^^^^^^^^^^^^ The ``klaus`` module contains a ``make_app`` function which returns a WSGI app. An example WSGI helper script is provided with klaus (see ``klaus/contrib/wsgi.py``), configuration being read from environment variables. Use it like this (uWSGI example):: uwsgi -w klaus.contrib.wsgi \ --env KLAUS_SITE_NAME="Klaus Demo" \ --env KLAUS_REPOS="/path/to/repo1 /path/to/repo2 ..." \ ... Gunicorn example:: gunicorn --env KLAUS_SITE_NAME="Klaus Demo" \ --env KLAUS_REPOS="/path/to/repo1 /path/to/repo2 ..." \ klaus.contrib.wsgi The Docker image also has uwsgi preinstalled:: docker run ... jonashaag/klaus:latest uwsgi ... See also `deployment section in the wiki `_. .. _wsgiref: http://docs.python.org/library/wsgiref.html Contributing ------------ Please do it! I'm equally happy with bug reports/feature ideas and code contributions. If you have any questions/issues, I'm happy to help! For starters, `here are a few ideas what to work on. `_ :-) |img1|_ |img2|_ |img3|_ .. |img1| image:: https://i.imgur.com/2XhZIgw.png .. |img2| image:: https://i.imgur.com/6LjC8Cl.png .. |img3| image:: https://i.imgur.com/EYJdQwv.png .. _img1: https://i.imgur.com/MV3uFvw.png .. _img2: https://i.imgur.com/9HEZ3ro.png .. _img3: https://i.imgur.com/kx2HaTq.png ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805302.0 klaus-2.0.3/klaus.egg-info/SOURCES.txt0000644000076500000000000000220414436654366015516 0ustar00jwheelLICENSE MANIFEST.in README.rst klaus.1 pyproject.toml setup.py klaus/__init__.py klaus/cli.py klaus/ctagscache.py klaus/ctagsutils.py klaus/diff.py klaus/highlighting.py klaus/markup.py klaus/repo.py klaus/utils.py klaus/views.py klaus.egg-info/PKG-INFO klaus.egg-info/SOURCES.txt klaus.egg-info/dependency_links.txt klaus.egg-info/entry_points.txt klaus.egg-info/not-zip-safe klaus.egg-info/requires.txt klaus.egg-info/top_level.txt klaus/contrib/__init__.py klaus/contrib/app_args.py klaus/contrib/wsgi.py klaus/contrib/wsgi_autoreload.py klaus/contrib/wsgi_autoreloading.py klaus/static/favicon.png klaus/static/klaus.css klaus/static/klaus.js klaus/static/pygments.css klaus/static/robots.txt klaus/templates/base.html klaus/templates/blame_blob.html klaus/templates/history.html klaus/templates/history.inc.html klaus/templates/index.html klaus/templates/repo_list.html klaus/templates/skeleton.html klaus/templates/submodule.html klaus/templates/tree.inc.html klaus/templates/view_blob.html klaus/templates/view_commit.html tests/test_blame.py tests/test_contrib.py tests/test_make_app.py tests/test_manpage.py tests/test_utils.py tests/test_views.py././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805302.0 klaus-2.0.3/klaus.egg-info/dependency_links.txt0000644000076500000000000000000114436654366017702 0ustar00jwheel ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805302.0 klaus-2.0.3/klaus.egg-info/entry_points.txt0000644000076500000000000000005114436654366017126 0ustar00jwheel[console_scripts] klaus = klaus.cli:main ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805302.0 klaus-2.0.3/klaus.egg-info/not-zip-safe0000644000076500000000000000000114436654366016062 0ustar00jwheel ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805302.0 klaus-2.0.3/klaus.egg-info/requires.txt0000644000076500000000000000021714436654366016234 0ustar00jwheelflask Werkzeug>=0.15.0 pygments httpauth humanize [:python_version < "3.5"] dulwich<0.20,>=0.19.3 [:python_version >= "3.5"] dulwich>=0.19.3 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805302.0 klaus-2.0.3/klaus.egg-info/top_level.txt0000644000076500000000000000000614436654366016362 0ustar00jwheelklaus ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/pyproject.toml0000644000076500000000000000012114436654075013726 0ustar00jwheel[build-system] requires = ["setuptools"] build-backend = "setuptools.build_meta" ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1685805302.8897493 klaus-2.0.3/setup.cfg0000644000076500000000000000004614436654367012645 0ustar00jwheel[egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805266.0 klaus-2.0.3/setup.py0000644000076500000000000000333414436654322012530 0ustar00jwheel# encoding: utf-8 import os from setuptools import setup long_description = open(os.path.join(os.path.dirname(__file__), "README.rst")).read() def install_data_files_hack(): # This is a clever hack to circumvent distutil's data_files # policy "install once, find never". Definitely a TODO! # -- https://groups.google.com/group/comp.lang.python/msg/2105ee4d9e8042cb from distutils.command.install import INSTALL_SCHEMES for scheme in INSTALL_SCHEMES.values(): scheme["data"] = scheme["purelib"] install_data_files_hack() requires = [ "flask", "Werkzeug>=0.15.0", "pygments", "httpauth", "humanize", 'dulwich>=0.19.3;python_version>="3.5"', 'dulwich>=0.19.3,<0.20;python_version<"3.5"', ] setup( name="klaus", version="2.0.3", author="Jonas Haag", author_email="jonas@lophus.org", packages=["klaus", "klaus.contrib"], include_package_data=True, zip_safe=False, url="https://github.com/jonashaag/klaus", description="The first Git web viewer that Just Works™.", long_description=long_description, classifiers=[ "Development Status :: 5 - Production/Stable", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Internet :: WWW/HTTP :: WSGI :: Application", "Topic :: Software Development :: Version Control", "Environment :: Web Environment", "Intended Audience :: Developers", "License :: OSI Approved :: ISC License (ISCL)", "Programming Language :: Python", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", ], install_requires=requires, entry_points={ "console_scripts": ["klaus=klaus.cli:main"], }, ) ././@PaxHeader0000000000000000000000000000003400000000000010212 xustar0028 mtime=1685805302.8894658 klaus-2.0.3/tests/0000755000076500000000000000000014436654367012166 5ustar00jwheel././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/tests/test_blame.py0000644000076500000000000000176314436654075014662 0ustar00jwheelimport requests from .utils import * def test_blame(): with serve(): response = requests.get(UNAUTH_TEST_REPO_URL + "blame/HEAD/test.c") assert response.status_code == 200 def test_blame_empty(): with serve(): response = requests.get(UNAUTH_TEST_REPO_URL + "blame/HEAD/empty.txt") assert response.status_code == 200 def test_dont_show_blame_link(): with serve(): for file in ["binary", "image.jpg", "toolarge"]: response = requests.get( UNAUTH_TEST_REPO_DONT_RENDER_URL + "blob/HEAD/" + file ).text assert "blame" not in response def test_dont_render_blame(): """Don't render blame even if someone navigated to the blame site by accident.""" with serve(): for file in ["binary", "image.jpg", "toolarge"]: response = requests.get( UNAUTH_TEST_REPO_DONT_RENDER_URL + "blame/HEAD/" + file ).text assert "Can't show blame" in response ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/tests/test_contrib.py0000644000076500000000000001071014436654075015232 0ustar00jwheelimport os try: from importlib import reload # Python 3.4+ except ImportError: pass import subprocess import pytest import requests from klaus.contrib import app_args from .utils import * def check_env(env, expected_args, expected_kwargs): os.environ.update(env) args, kwargs = app_args.get_args_from_env() assert args == expected_args assert kwargs == expected_kwargs def test_minimum_env(monkeypatch): """Test to provide only required env var""" monkeypatch.setattr(os, "environ", os.environ.copy()) check_env( {"KLAUS_SITE_NAME": TEST_SITE_NAME}, ([], TEST_SITE_NAME), dict( htdigest_file=None, use_smarthttp=False, require_browser_auth=False, disable_push=False, unauthenticated_push=False, ctags_policy="none", ), ) def test_complete_env(monkeypatch): """Test to provide all supported env var""" monkeypatch.setattr(os, "environ", os.environ.copy()) check_env( { "KLAUS_REPOS": TEST_REPO_NO_NAMESPACE, "KLAUS_SITE_NAME": TEST_SITE_NAME, "KLAUS_HTDIGEST_FILE": HTDIGEST_FILE, "KLAUS_USE_SMARTHTTP": "yes", "KLAUS_REQUIRE_BROWSER_AUTH": "1", "KLAUS_DISABLE_PUSH": "false", "KLAUS_UNAUTHENTICATED_PUSH": "0", "KLAUS_CTAGS_POLICY": "ALL", }, ([TEST_REPO_NO_NAMESPACE], TEST_SITE_NAME), dict( htdigest_file=HTDIGEST_FILE, use_smarthttp=True, require_browser_auth=True, disable_push=False, unauthenticated_push=False, ctags_policy="ALL", ), ) def test_unsupported_boolean_env(monkeypatch): """Test that unsupported boolean env var raises ValueError""" monkeypatch.setattr(os, "environ", os.environ.copy()) with pytest.raises(ValueError): check_env( { "KLAUS_REPOS": TEST_REPO_NO_NAMESPACE, "KLAUS_SITE_NAME": TEST_SITE_NAME, "KLAUS_HTDIGEST_FILE": HTDIGEST_FILE, "KLAUS_USE_SMARTHTTP": "unsupported", }, (), {}, ) def test_wsgi(monkeypatch): """Test start of wsgi app""" monkeypatch.setattr(os, "environ", os.environ.copy()) os.environ["KLAUS_REPOS"] = TEST_REPO_NO_NAMESPACE os.environ["KLAUS_SITE_NAME"] = TEST_SITE_NAME from klaus.contrib import wsgi with serve_app(wsgi.application): assert can_reach_unauth() assert not can_push_auth() os.environ["KLAUS_HTDIGEST_FILE"] = HTDIGEST_FILE os.environ["KLAUS_USE_SMARTHTTP"] = "yes" reload(wsgi) with serve_app(wsgi.application): assert can_reach_unauth() assert can_push_auth() def test_wsgi_autoreload(monkeypatch): """Test start of wsgi autoreload app""" monkeypatch.setattr(os, "environ", os.environ.copy()) os.environ["KLAUS_REPOS_ROOT"] = TEST_REPO_NO_NAMESPACE_ROOT os.environ["KLAUS_SITE_NAME"] = TEST_SITE_NAME from klaus.contrib import wsgi_autoreload, wsgi_autoreloading with serve_app(wsgi_autoreload.application): assert can_reach_unauth() assert not can_push_auth() os.environ["KLAUS_HTDIGEST_FILE"] = HTDIGEST_FILE os.environ["KLAUS_USE_SMARTHTTP"] = "yes" reload(wsgi_autoreload) reload(wsgi_autoreloading) with serve_app(wsgi_autoreload.application): assert can_push_auth() def can_reach_unauth(): return _check_http200(_GET_unauth, TEST_REPO_NO_NAMESPACE_BASE_URL) def can_push_auth(): return _can_push(_GET_auth, AUTH_TEST_REPO_NO_NAMESPACE_URL) def _can_push(http_get, url): return any( [ _check_http200( http_get, TEST_REPO_NO_NAMESPACE_BASE_URL + "info/refs?service=git-receive-pack", ), _check_http200( http_get, TEST_REPO_NO_NAMESPACE_BASE_URL + "git-receive-pack" ), subprocess.call(["git", "push", url, "master"], cwd=TEST_REPO_NO_NAMESPACE) == 0, ] ) def _GET_unauth(url=""): return requests.get( UNAUTH_TEST_SERVER + url, auth=requests.auth.HTTPDigestAuth("invalid", "password"), ) def _GET_auth(url=""): return requests.get( AUTH_TEST_SERVER + url, auth=requests.auth.HTTPDigestAuth("testuser", "testpassword"), ) def _check_http200(http_get, url): return http_get(url).status_code == 200 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/tests/test_make_app.py0000644000076500000000000001346114436654075015355 0ustar00jwheelimport re import subprocess import tempfile import shutil import klaus import pytest import requests import requests.auth from .utils import * def test_make_app_using_list(): app = klaus.make_app(REPOS, TEST_SITE_NAME) with serve_app(app): response = requests.get(UNAUTH_TEST_SERVER).text assert TEST_REPO_NO_NEWLINE_BASE_URL in response def test_htdigest_file_without_smarthttp_or_require_browser_auth(): with pytest.raises(ValueError): klaus.make_app([], None, htdigest_file=object()) def test_unauthenticated_push_and_require_browser_auth(): with pytest.raises(ValueError): klaus.make_app( [], None, use_smarthttp=True, unauthenticated_push=True, require_browser_auth=True, ) def test_unauthenticated_push_without_use_smarthttp(): with pytest.raises(ValueError): klaus.make_app([], None, unauthenticated_push=True) def test_unauthenticated_push_with_disable_push(): with pytest.raises(ValueError): klaus.make_app([], None, unauthenticated_push=True, disable_push=True) def options_test(make_app_args, expected_permissions): def test(): with serve(**make_app_args): for check, permitted in expected_permissions.items(): if check in globals(): checks = [check] elif check.endswith("auth"): checks = ["can_%s" % check] else: checks = ["can_%s_unauth" % check, "can_%s_auth" % check] for check in checks: assert globals()[check]() == permitted, check return test test_nosmart_noauth = options_test({}, {"reach": True, "clone": False, "push": False}) test_smart_noauth = options_test( {"use_smarthttp": True}, {"reach": True, "clone": True, "push": False} ) test_smart_push = options_test( {"use_smarthttp": True, "htdigest_file": open(HTDIGEST_FILE)}, {"reach": True, "clone": True, "push_auth": True, "push_unauth": False}, ) test_unauthenticated_push = options_test( {"use_smarthttp": True, "unauthenticated_push": True}, {"reach": True, "clone": True, "push": True}, ) test_nosmart_auth = options_test( {"require_browser_auth": True, "htdigest_file": open(HTDIGEST_FILE)}, {"reach_auth": True, "reach_unauth": False, "clone": False, "push": False}, ) test_smart_auth = options_test( { "require_browser_auth": True, "use_smarthttp": True, "htdigest_file": open(HTDIGEST_FILE), }, { "reach_auth": True, "reach_unauth": False, "clone_auth": True, "clone_unauth": False, "push_unauth": False, "push_auth": True, }, ) test_smart_auth_disable_push = options_test( { "require_browser_auth": True, "use_smarthttp": True, "disable_push": True, "htdigest_file": open(HTDIGEST_FILE), }, { "reach_auth": True, "reach_unauth": False, "clone_auth": True, "clone_unauth": False, "push": False, }, ) test_ctags_disabled = options_test( {}, {"ctags_tags_and_branches": False, "ctags_all": False} ) test_ctags_tags_and_branches = options_test( {"ctags_policy": "tags-and-branches"}, {"ctags_tags_and_branches": True, "ctags_all": False}, ) test_ctags_all = options_test( {"ctags_policy": "ALL"}, {"ctags_tags_and_branches": True, "ctags_all": True} ) # Reach def can_reach_unauth(): return _check_http200(_GET_unauth, TEST_REPO_BASE_URL) def can_reach_auth(): return _check_http200(_GET_auth, TEST_REPO_BASE_URL) # Clone def can_clone_unauth(): return _can_clone(_GET_unauth, UNAUTH_TEST_REPO_URL) def can_clone_auth(): return _can_clone(_GET_auth, AUTH_TEST_REPO_URL) def _can_clone(http_get, url): tmp = tempfile.mkdtemp() try: return any( [ "git clone" in http_get(TEST_REPO_BASE_URL).text, _check_http200( http_get, TEST_REPO_BASE_URL + "info/refs?service=git-upload-pack" ), subprocess.call(["git", "clone", url, tmp]) == 0, ] ) finally: shutil.rmtree(tmp, ignore_errors=True) # Push def can_push_unauth(): return _can_push(_GET_unauth, UNAUTH_TEST_REPO_URL) def can_push_auth(): return _can_push(_GET_auth, AUTH_TEST_REPO_URL) def _can_push(http_get, url): return any( [ _check_http200( http_get, TEST_REPO_BASE_URL + "info/refs?service=git-receive-pack" ), _check_http200(http_get, TEST_REPO_BASE_URL + "git-receive-pack"), subprocess.call(["git", "push", url, "master"], cwd=TEST_REPO) == 0, ] ) # Ctags def ctags_tags_and_branches(): return all( _ctags_enabled(ref, f) for ref in ["master", "tag1"] for f in ["test.c", "test.js"] ) def ctags_all(): all_refs = re.findall( 'href=".+/commit/([a-z0-9]{40})/">', requests.get(UNAUTH_TEST_REPO_URL).text ) assert len(all_refs) == 3 return all( _ctags_enabled(ref, f) for ref in all_refs for f in ["test.c", "test.js"] ) def _ctags_enabled(ref, filename): response = requests.get(UNAUTH_TEST_REPO_URL + "blob/%s/%s" % (ref, filename)) assert response.status_code == 200, response.text href = '' % (TEST_REPO_BASE_URL, ref, filename) return href in response.text def _GET_unauth(url=""): return requests.get( UNAUTH_TEST_SERVER + url, auth=requests.auth.HTTPDigestAuth("invalid", "password"), ) def _GET_auth(url=""): return requests.get( AUTH_TEST_SERVER + url, auth=requests.auth.HTTPDigestAuth("testuser", "testpassword"), ) def _check_http200(http_get, url): return http_get(url).status_code == 200 ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/tests/test_manpage.py0000644000076500000000000000210714436654075015203 0ustar00jwheelimport re import subprocess import shutil try: from unittest import mock except ImportError: import mock from klaus.utils import force_unicode def test_covers_all_cli_options(): if hasattr(shutil, "which") and not shutil.which("man"): return import klaus.cli manpage = force_unicode(subprocess.check_output(["man", "./klaus.1"])) def assert_in_manpage(s): def clean(x): return re.sub("(.\\x08)|\\s", "", x) assert clean(s) in clean(manpage), "%r not found in manpage" % s mock_parser = mock.Mock() with mock.patch("argparse.ArgumentParser") as mock_cls: mock_cls.return_value = mock_parser klaus.cli.make_parser() for args, kwargs in mock_parser.add_argument.call_args_list: if kwargs.get("metavar") == "DIR": continue for string in args: assert_in_manpage(string) if "help" in kwargs: assert_in_manpage(kwargs["help"]) if "choices" in kwargs: for choice in kwargs["choices"]: assert_in_manpage(choice) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/tests/test_utils.py0000644000076500000000000000230614436654075014734 0ustar00jwheelimport sys import unittest try: from unittest import mock except ImportError: import mock from klaus import utils class ForceUnicodeTests(unittest.TestCase): def test_ascii(self): self.assertEqual(u"foo", utils.force_unicode(b"foo")) def test_utf8(self): if sys.version_info[0] < 3: return self.assertEqual(eval(r'"f\xce"'), utils.force_unicode(eval(r'b"f\xc3\x8e"'))) def test_invalid(self): if sys.platform.startswith("win") or sys.version_info[0] < 3: return with mock.patch.object(utils, "chardet", None): self.assertEqual(b"f\xc3\x8e", utils.force_unicode(b"f\xce").encode("utf8")) class TarballBasenameTests(unittest.TestCase): def test_examples(self): examples = [ ("v0.1", "klaus-0.1"), ("klaus-0.1", "klaus-0.1"), ("0.1", "klaus-0.1"), ( "b3e70e08344ca3f83cc7033ecdbefa90443d7d2e", "klaus@b3e70e08344ca3f83cc7033ecdbefa90443d7d2e", ), ("vanilla", "klaus-vanilla"), ] for (rev, basename) in examples: self.assertEqual(utils.tarball_basename("klaus", rev), basename) ././@PaxHeader0000000000000000000000000000002600000000000010213 xustar0022 mtime=1685805117.0 klaus-2.0.3/tests/test_views.py0000644000076500000000000000553214436654075014735 0ustar00jwheelfrom io import BytesIO import requests import tarfile import contextlib from .utils import * def test_repo_list(): with serve(): response = requests.get(UNAUTH_TEST_SERVER).text assert TEST_REPO_BASE_URL in response assert TEST_REPO_DONT_RENDER_BASE_URL in response assert TEST_REPO_NO_NEWLINE_BASE_URL in response assert TEST_INVALID_REPO_NAME in response def test_repo_list_search_repo(): with serve(): response = requests.get( UNAUTH_TEST_SERVER + "?q=" + TEST_INVALID_REPO_NAME ).text assert TEST_REPO_BASE_URL not in response assert TEST_REPO_DONT_RENDER_BASE_URL not in response assert TEST_REPO_NO_NEWLINE_BASE_URL not in response assert TEST_INVALID_REPO_NAME in response def test_repo_list_search_namespace(): with serve(): response = requests.get(UNAUTH_TEST_SERVER + "?q=" + NAMESPACE).text assert TEST_REPO_BASE_URL in response assert TEST_REPO_DONT_RENDER_BASE_URL not in response assert TEST_REPO_NO_NEWLINE_BASE_URL not in response assert TEST_INVALID_REPO_NAME not in response def test_download(): with serve(): response = requests.get(UNAUTH_TEST_REPO_URL + "tarball/master/", stream=True) response_body = BytesIO(response.raw.read()) tarball = tarfile.TarFile.gzopen("test.tar.gz", fileobj=response_body) with contextlib.closing(tarball): assert tarball.extractfile("test_repo@master/test.c").read() == b"int a;\n" def test_no_newline_at_end_of_file(): with serve(): response = requests.get(UNAUTH_TEST_REPO_NO_NEWLINE_URL + "commit/HEAD/").text assert response.count("No newline at end of file") == 1 def test_dont_render_binary(): with serve(): response = requests.get( UNAUTH_TEST_REPO_DONT_RENDER_URL + "blob/HEAD/binary" ).text assert "Binary data not shown" in response def test_render_image(): with serve(): response = requests.get( UNAUTH_TEST_REPO_DONT_RENDER_URL + "blob/HEAD/image.jpg" ).text assert '' in response assert "
invalid_repo
" in response