Repository: jdowner/gist Branch: master Commit: b967010044f8 Files: 25 Total size: 78.6 KB Directory structure: gitextract_stv4va3s/ ├── .flake8 ├── .github/ │ └── workflows/ │ └── ci.yaml ├── .gitignore ├── LICENSE ├── Makefile ├── README.rst ├── gist/ │ ├── __init__.py │ ├── client.py │ ├── gist.py │ └── version.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── share/ │ ├── gist-fzf.bash │ ├── gist-fzsl.bash │ ├── gist.bash │ ├── gist.fish │ └── gist.zsh ├── tests/ │ ├── __init__.py │ ├── conftest.py │ ├── test_cli.py │ ├── test_cli_parser.py │ ├── test_config.py │ └── test_gist.py └── tox.ini ================================================ FILE CONTENTS ================================================ ================================================ FILE: .flake8 ================================================ [flake8] max-line-length = 88 import-order-style = appnexus ================================================ FILE: .github/workflows/ci.yaml ================================================ name: gist continuous integration on: [pull_request, push] jobs: build: strategy: max-parallel: 16 matrix: python-version: [3.6, 3.7, 3.8, 3.9] os: [macos-latest, ubuntu-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v1 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v1 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip python -m pip install poetry poetry install - name: Lint run: | make lint - name: Test run: | make test ================================================ FILE: .gitignore ================================================ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] # C extensions *.so # Distribution / packaging .Python env/ venv/ build/ develop-eggs/ dist/ downloads/ eggs/ lib/ lib64/ parts/ sdist/ var/ *.egg-info/ .installed.cfg *.egg *.eggs # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .cache nosetests.xml coverage.xml # Translations *.mo *.pot # Django stuff: *.log # Sphinx documentation docs/_build/ # PyBuilder target/ installed-files.txt bin/gistc tests/gnupg/* .vscode .envrc .python-version .vimlocal tags .venv/ .poetry-install-run ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2015 Joshua Downer Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ SHELL=/bin/bash TEST_FILES:=$(wildcard tests/*.py) SRC_FILES:=$(wildcard gist/*.py) REQ_BINS := poetry $(foreach bin,$(REQ_BINS),\ $(if $(shell which $(bin) 2> /dev/null),,$(error Missing required package `$(bin)`))) .PHONY: build test lint tox clean export .poetry-install-run: @poetry install --remove-untracked @touch .poetry-install-run build: @poetry build test: .poetry-install-run @poetry run pytest --ff -x -v -s tests lint: .poetry-install-run @poetry run black --quiet --check $(TEST_FILES) $(SRC_FILES) @poetry run flake8 $(TEST_FILES) $(SRC_FILES) tox: .poetry-install-run @poetry run tox clean: git clean -xdf export: @poetry export --without-hashes -o requirements.txt @poetry export --dev --without-hashes -o requirements-dev.txt ================================================ FILE: README.rst ================================================ ================================================== GIST ================================================== 'gist' is a command line interface for working with github gists. It provides several methods for inspecting a users gists, and the ability to easily create them. .. image:: https://github.com/jdowner/gist/workflows/gist%20continuous%20integration/badge.svg :target: https://github.com/jdowner/gist Requirements -------------------------------------------------- Python 3.6, 3.7, 3.8, or 3.9 is required. Installation -------------------------------------------------- The preferred way to install 'gist' is from pypi.org using pip (or pip3), :: $ pip install python-gist Alternatively, you can clone the repository and install it manually, :: $ pip install . The 'share' directory contains a set of shell scripts that provide tab completion and fuzzy search for gist. There are 3 different scripts for tab-completion in bash: gist.bash, gist-fzf.bash, and gist-fzsl.bash. The first provides simple tab completion and can be enable by adding the following to your .bashrc file, :: source /usr/local/share/gist/gist.bash The other scripts, gist-fzf.bash and fist-fzsl.bash, provide fuzzy matching of gists using an ncurses interface (NB: these scripts require `fzf `_ and `fzsl `_, respectively). The gist.fish script provides tab completion for the fish shell, and should be copied to ~/.config/fish/completions. The gist.zsh script provides tab completion for the zsh shell, and should be copied to ~/.zsh as _gist. If not already in your ~/.zshrc file, you should add :: fpath=(${HOME}/.zsh $fpath) To check that 'gist' is operating correctly, you can run the unit tests with, :: $ make test Note that running the unit tests requires `poetry `_ to be available on your PATH. Getting started -------------------------------------------------- 'gist' requires a personal access token for authentication. To create a token, go to https://github.com/settings/tokens. The token needs to then be added to a 'gist' configuration file that should have the form, :: [gist] token: editor: The editor field is optional. If the default editor is specified through some other mechanism 'gist' will try to infer it. Otherwise, you can use the config file to ensure that 'gist' uses the editor you want it to use. If the token string begins with ``!`` the text following is interpreted as a shell command which, when executed, prints the token to stdout. For example:: [gist] token: !gpg --decrypt github-token.gpg The configuration file must be in one of the following, :: ${XDG_DATA_HOME}/gist ${HOME}/.config/gist ${HOME}/.gist If more than one of these files exist, this is also the order of preference, i.e. a configuration that is found in the ``${XDG_DATA_HOME}`` directory will be taken in preference to ``${HOME}/.config/gist``. Also, 'gist' assumes that you have set up your github account to use SSH keys so that you can access your repositories without needing to provide a password. Here__ is a link on setting up SSH keys with github. __ https://help.github.com/articles/connecting-to-github-with-ssh/ Usage -------------------------------------------------- 'gist' is intended to make it easy to manage and use github gists from the command line. There are several commands available: :: gist create - creates a new gist gist edit - edit the files in your gist gist description - updates the description of your gist gist list - prints a list of your gists gist clone - clones a gist gist delete - deletes a gist or list of gists from github gist files - prints a list of the files in a gist gist archive - downloads a gist and creates a tarball gist content - prints the content of the gist to stdout gist info - prints detailed information about a gist gist version - prints the current version gist help - prints the help documentation **gist create** Most of the 'gist' commands are pretty simple and limited in what they can do. 'gist create' is a little different and offers more flexibility in how the user can create the gist. If you have a set of existing files that you want to turn into a gist, :: $ gist create "divide et impera" foo.txt bar.txt where the quoted string is the description of the gist. Or, you may find it useful to create a gist from content on your clipboard (say, using xclip), :: $ xclip -o | gist create "ipsa scientia potestas est" Another option is to pipe the input into 'gist create' and have it automatically put the content on github, :: $ echo $(cat) | gist create "credo quia absurdum est" Finally, you can just call, :: $ gist create "a posse ad esse" which will launch your default editor (defined by the EDITOR environment variable). In addition to creating gists using the above methods, it is also possible to encrypt a gist if you have gnupg installed. Any of the above methods can be used to create encrypted gists by simply adding the --encrypt flag to invocation. For example, :: $ gist create "arcana imperii" --encrypt will open the editor allowing you to create the content of the gist, which is then encrypted and added to github. See the Configuration section for information on how to enable gnupg support. **gist edit** You can edit your gists directly with the 'edit' command. This command will clone the gist to a temporary directory and open up the default editor (defined by the EDITOR environment variable) to edit the files in the gist. When the editor is exited the user is prompted to commit the changes, which are then pushed back to the remote. **gist description** You can update the description of your gist with the 'description' command. You need to supply the gist ID and the new description. For example - :: $ gist description e1f5e95a1705cbfde144 "This is a new description" **gist list** Returns a list of your gists. The gists are returned as, :: 2b1823252e8433ef8682 - mathematical divagations a485ee9ddf6828d697be - notes on defenestration 589071c7a02b1823252e + abecedarian pericombobulations The first column is the gists unique identifier; The second column indicates whether the gist is public ('+') or private ('-'); The third column is the description in the gist, which may be empty. **gist clone** Clones a gist to the current directory. This command will clone any gist based on its unique identifier (i.e. not just the users) to the current directory. **gist delete** Deletes the specified gists from github. **gist files** Returns a list of the files in the specified gist. **gist archive** Downloads the specified gist to a temporary directory and adds it to a tarball, which is then moved to the current directory. **gist content** Writes the content of each file in the specified gist to the terminal, e.g. :: $ gist content c971fca7997aed65ddc9 foo.txt: this is foo bar.txt: this is bar For each file in the gist the first line is the name of the file followed by a colon, and then the content of that file is written to the terminal. If a filename is given, only the content of the specified filename will be printed. :: $ gist content de42344a4ecb6250d6cea00d9da6d83a file1 content of file 1 If the contents of the gist is encrypted, it can be viewed in its decrypted form by adding the --decrypt flag, e.g. :: $ gist content --decrypt 8fe557fb3771aa74edfd foo.txt.asc (decrypted): this is a secret See the Configuration section for information on how to enable gnupg support. **gist info** This command provides a complete dump of the information about the gist as a JSON object. It is mostly useful for debugging. **gist version** Simply prints the current version. **gist help** Prints out the help documentation. Configuration -------------------------------------------------- There are several parameters that can be added to a configuration file to determine the behavior of gist. The configuration file itself is expected to be one of the following paths, :: ${HOME}/.gist ${HOME}/.config/gist ${XDG_DATA_HOME}/gist The configuration file follows the .ini style. The following is an example, :: [gist] token: dde7b84d1e0edf7454ab354934b6ab36b01bf00f editor: /usr/bin/vim gnupg-homedir: /home/user/.gnupg gnupg-fingerprint: 179F9650D9FC1BFE391620B4B13A7829D8DE8623 delete-tempfiles: False The only essential field in the configuration file is the token. This is the authentication token from github that grants gist permission to access your gists. The editor is the editor to use if the EDITOR environment is not set or you wish to use a different editor. 'gnupg-homedir' is the directory where your gnupg data are stored, and 'gnupg-fingerprint' is the fingerprint of the key to use to encrypt data in your gists. Both gnupg fields are required to support encryption/decryption. The 'delete-tempfiles' option is used when gists are created from an editor. The editor writes its contents to a temporary file, which is deleted by default. The default behavior can be overridden by using the 'delete-tempfiles' flag. Contributors -------------------------------------------------- Thank you to the following people for contributing to 'gist'! * Eren Inan Canpolat (https://github.com/canpolat) * Kaan Genç (https://github.com/SeriousBug) * Eric James Michael Ritz (https://github.com/ejmr) * Karan Parikh (https://github.com/karanparikh) * Konstantin Krastev (https://github.com/grizmin) * Brandon Davidson (https://github.com/brandond) * jq170727 (https://github.com/jq170727) * jsbronder (https://github.com/jsbronder) * hugsy (https://github.com/hugsy) * Kenneth Benzie (https://github.com/kbenzie) * rtfmoz2 (https://github.com/rtfmoz2) ================================================ FILE: gist/__init__.py ================================================ ================================================ FILE: gist/client.py ================================================ #!/usr/bin/env python # -*- coding: utf-8 -*- """ Name: gist Usage: gist help gist list gist edit gist description gist info gist fork gist files gist delete ... gist archive gist content [] [--decrypt] gist create [--public] [--encrypt] [FILES ...] gist create [--public] [--encrypt] [--filename ] gist clone [] gist version Description: This program provides a command line interface for interacting with github gists. Commands: help Shows this documentation. create Create a new gist. A gist can be created in several ways. The content of the gist can be piped to the gist, $ echo "this is the content" | gist create "gist description" The gist can be created from an existing set of files, $ gist create "gist description" foo.txt bar.txt The gist can be created on the fly, $ gist create "gist description" which will open the users default editor. If you are creating a gist with a single file using either the pipe or 'on the fly' method above, you can also supply an optional argument to name the file instead of using the default ('file1.txt'), $ gist create "gist description" --filename foo.md Note that the use of --filename is incompatible with passing in a list of existing files. edit You can edit your gists directly with the 'edit' command. This command will clone the gist to a temporary directory and open up the default editor (defined by the EDITOR environment variable) to edit the files in the gist. When the editor is exited the user is prompted to commit the changes, which are then pushed back to the remote. fork Creates a fork of the specified gist. description Updates the description of a gist. list Returns a list of your gists. The gists are returned as, 2b1823252e8433ef8682 - mathematical divagations a485ee9ddf6828d697be - notes on defenestration 589071c7a02b1823252e + abecedarian pericombobulations The first column is the gists unique identifier; The second column indicates whether the gist is public ('+') or private ('-'); The third column is the description in the gist, which may be empty. clone Clones a gist to the current directory. This command will clone any gist based on its unique identifier (i.e. not just the users) to the current directory. delete Deletes the specified gist. files Returns a list of the files in the specified gist. archive Downloads the specified gist to a temporary directory and adds it to a tarball, which is then moved to the current directory. content Writes the content of each file in the specified gist to the terminal, e.g. $ gist content c971fca7997aed65ddc9 foo.txt: this is foo bar.txt: this is bar For each file in the gist the first line is the name of the file followed by a colon, and then the content of that file is written to the terminal. If a filename is given, only the content of the specified filename will be printed. $ gist content de42344a4ecb6250d6cea00d9da6d83a file1 content of file 1 info This command provides a complete dump of the information about the gist as a JSON object. It is mostly useful for debugging. version Returns the current version of gist. """ import argparse import codecs import collections import configparser import json import locale import logging import os import pathlib import platform import shlex import struct import subprocess import sys import tempfile import gnupg from . import gist from . import version if platform.system() != "Windows": # those modules exist everywhere but on Windows import termios import fcntl logger = logging.getLogger("gist") def wrap_stdout_for_unicode(): """ We need to wrap stdout in order to properly handle piping unicode output. However, detaching stdout can cause problems when trying to run tests. Therefore this logic is placed inside this function so that it can be disabled (monkeypatched) when tests are run. """ encoding = locale.getpreferredencoding() sys.stdout = codecs.getwriter(encoding)(sys.stdout.detach()) class GistError(Exception): def __init__(self, msg): super(GistError, self).__init__(msg) self.msg = msg class GistMissingTokenError(GistError): pass class GistEmptyTokenError(GistError): pass class UserError(Exception): pass class FileInfo(collections.namedtuple("FileInfo", "name content")): pass def terminal_width(): """Returns the terminal width Tries to determine the width of the terminal. If there is no terminal, then None is returned instead. """ try: if platform.system() == "Windows": from ctypes import windll, create_string_buffer # Reference: https://docs.microsoft.com/en-us/windows/console/getstdhandle # noqa hStdErr = -12 get_console_info_fmtstr = "hhhhHhhhhhh" herr = windll.kernel32.GetStdHandle(hStdErr) csbi = create_string_buffer(struct.calcsize(get_console_info_fmtstr)) if not windll.kernel32.GetConsoleScreenBufferInfo(herr, csbi): raise OSError("Failed to determine the terminal size") (_, _, _, _, _, left, top, right, bottom, _, _) = struct.unpack( get_console_info_fmtstr, csbi.raw ) tty_columns = right - left + 1 return tty_columns else: exitcode = fcntl.ioctl( 0, termios.TIOCGWINSZ, struct.pack("HHHH", 0, 0, 0, 0) ) h, w, hp, wp = struct.unpack("HHHH", exitcode) return w except Exception: pass def elide(txt): """Elide the text to the width of the current terminal. Arguments: txt: the string to potentially elide Returns: A string that is no longer than the specified width. """ width = terminal_width() if width is not None and width > 3: try: if len(txt) > width: return txt[: width - 3] + "..." except Exception: pass return txt def get_value_from_command(value): """Return the value of a config option, potentially by running a command When a config option begins with a ``!`` interpret the remaining text as a shell command which when run prints the config option value to stdout. Otherwise return the original string. Argument: value: value of an option returned from the config file. """ command = value.strip() if command[0] == "!": process = subprocess.Popen( shlex.split(command[1:]), stdout=subprocess.PIPE, stderr=subprocess.PIPE ) out, err = process.communicate() if process.returncode != 0: raise GistError(err) return out.decode().strip() return value def get_personal_access_token(config): """Returns the users personal access token Argument: config: a configuration object """ try: value = config.get("gist", "token").strip() if not value: raise GistEmptyTokenError("An empty token is not valid") except configparser.NoOptionError: raise GistMissingTokenError("Missing 'token' field in configuration") return get_value_from_command(value) def alternative_editor(default): """Return the path to the 'alternatives' editor Argument: default: the default to use if the alternatives editor cannot be found. """ if os.path.exists("/usr/bin/editor"): return "/usr/bin/editor" return default def environment_editor(default): """Return the user specified environment default Argument: default: the default to use if the environment variable contains nothing useful. """ editor = os.environ.get("EDITOR", "").strip() if editor != "": return editor return default def configuration_editor(config, default): """Return the editor in the config file Argument: default: the default to use if there is no editor in the config """ try: return config.get("gist", "editor") except configparser.NoOptionError: return default def homedir_config(default): """Return the path to the config file in the users home directory Argument: default: the default to use if ~/.gist does not exist. """ config_path = pathlib.Path("~").expanduser() / ".gist" return config_path if config_path.is_file() else default def alternative_config(default): """Return the path to the config file in .config directory Argument: default: the default to use if ~/.config/gist does not exist. """ config_path = pathlib.Path("~/.config/gist").expanduser() return config_path if config_path.is_file() else default def xdg_data_config(default): """Return the path to the config file in XDG user config directory Argument: default: the default to use if either the XDG_DATA_HOME environment is not set, or the XDG_DATA_HOME directory does not contain a 'gist' file. """ config_path = os.environ.get("XDG_DATA_HOME", None) if config_path is not None: config_path = pathlib.Path(config_path) / "gist" if config_path.is_file(): return config_path return default def environment_config(default): """Return the path to the config file defined in an environment variable Argument: default: the default to use if the environment variable GIST_CONFIG has not been set. """ config_path = os.environ.get("GIST_CONFIG", None) if config_path is not None: config_path = pathlib.Path(config_path) if config_path.is_file(): return config_path return default def load_config_file(): """ Returns a ConfigParser object with any gist related configuration data """ config = configparser.ConfigParser() config_path = homedir_config(None) config_path = alternative_config(config_path) config_path = xdg_data_config(config_path) config_path = environment_config(config_path) if config_path is None: raise UserError("unable to find config file") try: with open(config_path) as fp: config.read_file(fp) except Exception as e: raise UserError("Unable to load configuration file: {0}".format(e)) # Make sure the config contains a gist section. If it does not, create one so # that the following code can simply assume it exists. if not config.has_section("gist"): config.add_section("gist") return config def handle_gist_list(gapi, args, *vargs): """Handle 'gist list' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: list") gists = gapi.list() for info in gists: public = "+" if info.public else "-" desc = "" if info.desc is None else info.desc line = u"{} {} {}".format(info.id, public, desc) try: print(elide(line)) except UnicodeEncodeError: logger.error("unable to write gist {}".format(info.id)) def handle_gist_edit(gapi, args, *vargs): """Handle 'gist edit' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: edit") logger.debug(u"action: - {}".format(args.id)) gapi.edit(args.id) def handle_gist_description(gapi, args, *vargs): """Handle 'gist description' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: description") logger.debug(u"action: - {}".format(args.id)) logger.debug(u"action: - {}".format(args.desc)) gapi.description(args.id, args.desc) def handle_gist_info(gapi, args, *vargs): """Handle 'gist info' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: info") logger.debug(u"action: - {}".format(args.id)) info = gapi.info(args.id) print(json.dumps(info, indent=2)) def handle_gist_fork(gapi, args, *vargs): """Handle 'gist fork' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: fork") logger.debug(u"action: - {}".format(args.id)) _ = gapi.fork(args.id) def handle_gist_files(gapi, args, *vargs): """Handle 'gist files' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: files") logger.debug(u"action: - {}".format(args.id)) for f in gapi.files(args.id): print(f) def handle_gist_delete(gapi, args, *vargs): """Handle 'gist delete' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: delete") for gist_id in args.ids: logger.debug(u"action: - {}".format(gist_id)) gapi.delete(gist_id) def handle_gist_archive(gapi, args, *vargs): """Handle 'gist archive' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: archive") logger.debug(u"action: - {}".format(args.id)) gapi.archive(args.id) def handle_gist_content(gapi, args, config, *vargs): """Handle 'gist content' command Arguments: gapi: a GistAPI object args: parsed command line arguments config: configuration data """ logger.debug(u"action: content") logger.debug(u"action: - {}".format(args.id)) content = gapi.content(args.id) gist_file = content.get(args.filename) if args.decrypt: if not config.has_option("gist", "gnupg-homedir"): raise GistError("gnupg-homedir missing from config file") homedir = config.get("gist", "gnupg-homedir") logger.debug(u"action: - {}".format(homedir)) gpg = gnupg.GPG(gnupghome=homedir, use_agent=True) if gist_file is not None: print(gpg.decrypt(gist_file).data.decode("utf-8")) else: for name, lines in content.items(): lines = gpg.decrypt(lines).data.decode("utf-8") print(u"{} (decrypted):\n{}\n".format(name, lines)) else: if gist_file is not None: print(gist_file) else: for name, lines in content.items(): print(u"{}:\n{}\n".format(name, lines)) def handle_gist_create(gapi, args, config, editor, *vargs): """Handle 'gist create' command Arguments: gapi: a GistAPI object args: parsed command line arguments config: configuration data editor: editor command to use to create gist content """ logger.debug("action: create") # If encryption is selected, perform an initial check to make sure that # it is possible before processing any data. if args.encrypt: if not config.has_option("gist", "gnupg-homedir"): raise GistError("gnupg-homedir missing from config file") if not config.has_option("gist", "gnupg-fingerprint"): raise GistError("gnupg-fingerprint missing from config file") # Retrieve the data to add to the gist files = list() if sys.stdin.isatty(): if args.files: logger.debug("action: - reading from files") for path in args.files: name = os.path.basename(path) with open(path, "rb") as fp: files.append(FileInfo(name, fp.read().decode("utf-8"))) else: logger.debug("action: - reading from editor") filename = "file1.txt" if args.filename is None else args.filename # Determine whether the temporary file should be deleted if config.has_option("gist", "delete-tempfiles"): delete = config.getboolean("gist", "delete-tempfiles") else: delete = True with tempfile.NamedTemporaryFile("wb+", delete=delete) as fp: logger.debug("action: - created {}".format(fp.name)) os.system("{} {}".format(editor, fp.name)) fp.flush() fp.seek(0) files.append(FileInfo(filename, fp.read().decode("utf-8"))) if delete: logger.debug("action: - removed {}".format(fp.name)) else: logger.debug("action: - reading from stdin") filename = "file1.txt" if args.filename is None else args.filename files.append(FileInfo(filename, sys.stdin.read())) # Ensure that there are no empty files for file in files: if len(file.content) == 0: raise GistError("'{}' is empty".format(file.name)) # Encrypt the files or leave them unmodified if args.encrypt: logger.debug("action: - encrypting content") fingerprint = config.get("gist", "gnupg-fingerprint") gnupghome = config.get("gist", "gnupg-homedir") gpg = gnupg.GPG(gnupghome=gnupghome, use_agent=True) data = {} for file in files: cypher = gpg.encrypt(file.content.encode("utf-8"), fingerprint) content = cypher.data.decode("utf-8") data["{}.asc".format(file.name)] = {"content": content} else: data = {file.name: {"content": file.content} for file in files} print(gapi.create(args.desc, data, args.public)) def handle_gist_clone(gapi, args, *vargs): """Handle 'gist clone' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: clone") logger.debug(u"action: - {} as {}".format(args.id, args.name)) gapi.clone(args.id, args.name) def handle_gist_version(gapi, args, *vargs): """Handle 'gist version' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: version") print("v{}".format(version.__version__)) def handle_gist_help(gapi, args, *vargs): """Handle 'gist help' command Arguments: gapi: a GistAPI object args: parsed command line arguments """ logger.debug(u"action: help") print(__doc__) def create_gist_list_parser(subparser): """Create parser for 'gist list' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("list") parser.set_defaults(func=handle_gist_list) def create_gist_edit_parser(subparser): """Create parser for 'gist edit' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("edit") parser.add_argument("id") parser.set_defaults(func=handle_gist_edit) def create_gist_description_parser(subparser): """Create parser for 'gist description' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("description") parser.add_argument("id") parser.add_argument("desc") parser.set_defaults(func=handle_gist_description) def create_gist_info_parser(subparser): """Create parser for 'gist info' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("info") parser.add_argument("id") parser.set_defaults(func=handle_gist_info) def create_gist_fork_parser(subparser): """Create parser for 'gist fork' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("fork") parser.add_argument("id") parser.set_defaults(func=handle_gist_fork) def create_gist_files_parser(subparser): """Create parser for 'gist files' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("files") parser.add_argument("id") parser.set_defaults(func=handle_gist_files) def create_gist_delete_parser(subparser): """Create parser for 'gist delete' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("delete") parser.add_argument("ids", nargs="+") parser.set_defaults(func=handle_gist_delete) def create_gist_archive_parser(subparser): """Create parser for 'gist archive' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("archive") parser.add_argument("id") parser.set_defaults(func=handle_gist_archive) def create_gist_content_parser(subparser): """Create parser for 'gist content' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("content") parser.add_argument("id") parser.add_argument("filename", nargs="?", default=None) parser.add_argument("--decrypt", action="store_true") parser.set_defaults(func=handle_gist_content) def create_gist_create_parser(subparser): """Create parser for 'gist create' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("create") parser.add_argument("desc") parser.add_argument("--encrypt", action="store_true") parser.add_argument("--public", action="store_true") parser.add_argument("--filename") parser.add_argument("files", nargs="*") parser.set_defaults(func=handle_gist_create) def create_gist_clone_parser(subparser): """Create parser for 'gist clone' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("clone") parser.add_argument("id") parser.add_argument("name", nargs="?", default=None) parser.set_defaults(func=handle_gist_clone) def create_gist_version_parser(subparser): """Create parser for 'gist version' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("version") parser.set_defaults(func=handle_gist_version) def create_gist_help_parser(subparser): """Create parser for 'gist help' command Arguments: subparser: subparser object from primary parser """ parser = subparser.add_parser("help") parser.set_defaults(func=handle_gist_help) def create_gist_parser(): """Create main parser for 'gist' commands""" # Subclass the ArgumentParser so that we can override the 'error' function class Parser(argparse.ArgumentParser): def __init__(self, *args, **kwargs): kwargs["add_help"] = False super().__init__(*args, **kwargs) def error(self, message): raise UserError(message) parser = Parser() subparser = parser.add_subparsers() create_gist_list_parser(subparser) create_gist_edit_parser(subparser) create_gist_description_parser(subparser) create_gist_info_parser(subparser) create_gist_fork_parser(subparser) create_gist_files_parser(subparser) create_gist_delete_parser(subparser) create_gist_archive_parser(subparser) create_gist_content_parser(subparser) create_gist_create_parser(subparser) create_gist_clone_parser(subparser) create_gist_version_parser(subparser) create_gist_help_parser(subparser) return parser def main(argv=sys.argv[1:], config=None): try: wrap_stdout_for_unicode() # Setup logging fmt = "%(created).3f %(levelname)s[%(name)s] %(message)s" logging.basicConfig(format=fmt) # Read in the configuration file if config is None: config = load_config_file() try: log_level = config.get("gist", "log-level").upper() logging.getLogger("gist").setLevel(log_level) except Exception: logging.getLogger("gist").setLevel(logging.ERROR) # Determine the editor to use editor = None editor = alternative_editor(editor) editor = environment_editor(editor) editor = configuration_editor(config, editor) if editor is None: raise UserError("Unable to find an editor.") token = get_personal_access_token(config) gapi = gist.GistAPI(token=token, editor=editor) # Parser command line arguments parser = create_gist_parser() args = parser.parse_args(argv) args.func(gapi, args, config, editor) except UserError as e: sys.stderr.write(u"ERROR: {}\n".format(str(e))) sys.stderr.flush() sys.exit(1) except GistError as e: sys.stderr.write(u"GIST: {}\n".format(e.msg)) sys.stderr.flush() sys.exit(1) except Exception as e: logger.error(str(e)) sys.exit(1) if __name__ == "__main__": main() ================================================ FILE: gist/gist.py ================================================ import base64 import collections import contextlib import json import os import re import shutil import tarfile import tempfile import requests requests.packages.urllib3.disable_warnings() @contextlib.contextmanager def pushd(path): original = os.getcwd() os.chdir(path) yield os.chdir(original) class GistInfo(collections.namedtuple("GistInfo", "id public desc")): pass class authenticate(object): """ The class is used as a decorator to handle token authentication with github. """ def __init__(self, func, method="GET"): """Create an authenticate object Arguments: func: a function to decorate method: the method of the request to construct """ self.func = func self.owner = None self.instance = None self.headers = { "Accept-Encoding": "identity, deflate, compress, gzip", "User-Agent": "python-requests/1.2.0", "Accept": "application/vnd.github.v3.base64", } self.method = method @classmethod def get(cls, func): """Create an authenticate object with a GET method Arguments: func: a function to decorate """ return cls(func, method="GET") @classmethod def post(cls, func): """Create an authenticate object with a POST method Arguments: func: a function to decorate """ return cls(func, method="POST") @classmethod def patch(cls, func): """Create an authenticate object with a PATCH method Arguments: func: a function to decorate """ return cls(func, method="PATCH") @classmethod def delete(cls, func): """Create an authenticate object with a DELETE method Arguments: func: a function to decorate """ return cls(func, method="DELETE") def __get__(self, instance, owner): """Returns the __call__ method This method is part of the data descriptor interface. It returns the __call__ method, which wraps the original function. """ self.instance = instance self.owner = owner return self.__call__ def __call__(self, *args, **kwargs): """Wraps the original function and provides an initial request. The request object is created with the instance token as a query parameter, and specifies the required headers. """ try: url = "https://api.github.com/gists" token = self.instance.token self.headers["Authorization"] = "token {}".format(token) request = requests.Request(self.method, url, headers=self.headers) return self.func(self.instance, request, *args, **kwargs) finally: self.instance = None self.owner = None class GistAPI(object): """ This class defines the interface to github. """ def __init__(self, token, editor=None): """Create a GistAPI object Arguments: token: an authentication token editor: path to the editor to use when editing a gist """ self.token = token self.editor = editor self.session = requests.Session() def send(self, request, stem=None): """Prepare and send a request Arguments: request: a Request object that is not yet prepared stem: a path to append to the root URL Returns: The response to the request """ if stem is not None: request.url = request.url + "/" + stem.lstrip("/") prepped = self.session.prepare_request(request) settings = self.session.merge_environment_settings( url=prepped.url, proxies={}, stream=None, verify=None, cert=None ) response = self.session.send(prepped, **settings) if not response.ok: response.raise_for_status() return response def list(self): """Returns a list of the users gists as GistInfo objects Returns: a list of GistInfo objects """ # Define the basic request. The per_page parameter is set to 100, which # is the maximum github allows. If the user has more than one page of # gists, this request object will be modified to retrieve each # successive page of gists. request = requests.Request( "GET", "https://api.github.com/gists", headers={ "Accept-Encoding": "identity, deflate, compress, gzip", "User-Agent": "python-requests/1.2.0", "Accept": "application/vnd.github.v3.base64", "Authorization": "token {}".format(self.token), }, params={"per_page": 100}, ) # Github provides a 'link' header that contains information to # navigate through a users page of gists. This regex is used to # extract the URLs contained in this header, and to find the next page # of gists. pattern = re.compile(r'<([^>]*)>; rel="([^"]*)"') gists = [] while True: # Retrieve the next page of gists try: response = self.send(request).json() except Exception: break # Extract the list of gists for gist in response: try: gists.append( GistInfo( gist["id"], gist["public"], gist["description"], ) ) except KeyError: continue try: link = response.headers["link"] # Search for the next page of gist. If a 'next' page is found, # the URL is set to this new page and the iteration continues. # If there is no next page, return the list of gists. for result in pattern.finditer(link): url = result.group(1) rel = result.group(2) if rel == "next": request.url = url break else: return gists except Exception: break return gists @authenticate.post def create(self, request, desc, files, public=False): """Creates a gist Arguments: request: an initial request object desc: the gist description files: a list of files to add to the gist public: a flag to indicate whether the gist is public or not Returns: The URL to the newly created gist. """ request.data = json.dumps( {"description": desc, "public": public, "files": files} ) return self.send(request).json()["html_url"] @authenticate.delete def delete(self, request, id): """Deletes a gist Arguments: request: an initial request object id: the gist identifier """ self.send(request, id) @authenticate.get def info(self, request, id): """Returns info about a given gist Arguments: request: an initial request object id: the gist identifier Returns: A dict containing the gist info """ return self.send(request, id).json() @authenticate.get def files(self, request, id): """Returns a list of files in the gist Arguments: request: an initial request object id: the gist identifier Returns: A list of the files """ gist = self.send(request, id).json() return gist["files"] @authenticate.get def content(self, request, id): """Returns the content of the gist Arguments: request: an initial request object id: the gist identifier Returns: A dict containing the contents of each file in the gist """ gist = self.send(request, id).json() def convert(data): return base64.b64decode(data).decode("utf-8") content = {} for name, data in gist["files"].items(): content[name] = convert(data["content"]) return content @authenticate.get def archive(self, request, id): """Create an archive of a gist The files in the gist are downloaded and added to a compressed archive (tarball). If the ID of the gist was c78d925546e964b4b1df, the resulting archive would be, c78d925546e964b4b1df.tar.gz The archive is created in the directory where the command is invoked. Arguments: request: an initial request object id: the gist identifier """ gist = self.send(request, id).json() with tarfile.open("{}.tar.gz".format(id), mode="w:gz") as archive: for name, data in gist["files"].items(): with tempfile.NamedTemporaryFile("w+") as fp: fp.write(data["content"]) fp.flush() archive.add(fp.name, arcname=name) @authenticate.get def edit(self, request, id): """Edit a gist The files in the gist a cloned to a temporary directory and passed to the default editor (defined by the EDITOR environmental variable). When the user exits the editor, they will be provided with a prompt to commit the changes, which will then be pushed to the remote. Arguments: request: an initial request object id: the gist identifier """ with pushd(tempfile.gettempdir()): try: self.clone(id) with pushd(id): files = [f for f in os.listdir(".") if os.path.isfile(f)] quoted = ['"{}"'.format(f) for f in files] os.system("{} {}".format(self.editor, " ".join(quoted))) os.system("git commit -av && git push") finally: shutil.rmtree(id) @authenticate.post def fork(self, request, id): """Fork a gist Forks an existing gist. Arguments: request: an initial request object id: the gist identifier """ return self.send(request, "{}/forks".format(id)) @authenticate.patch def description(self, request, id, description): """Updates the description of a gist Arguments: request: an initial request object id: the id of the gist we want to edit the description for description: the new description """ request.data = json.dumps({"description": description}) return self.send(request, id).json()["html_url"] def clone(self, id, name=None): """Clone a gist Arguments: id: the gist identifier name: the name to give the cloned repo """ url = "git@gist.github.com:/{}".format(id) if name is None: os.system("git clone {}".format(url)) else: os.system("git clone {} {}".format(url, name)) ================================================ FILE: gist/version.py ================================================ __version__ = "0.10.6" # noqa ================================================ FILE: pyproject.toml ================================================ [tool.poetry] name = "python-gist" version = "0.10.6" description = "Manage github gists" authors = ["Joshua Downer "] license = "MIT" readme = "README.rst" keywords = ["gist", "github", "git"] homepage = "https://github.com/jdowner/gist" repository = "https://github.com/jdowner/gist" documentation = "https://github.com/jdowner/gist" classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Console", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Intended Audience :: System Administrators", "License :: OSI Approved :: MIT License", "Operating System :: Unix", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Topic :: Software Development", "Topic :: Software Development :: Version Control", "Topic :: Utilities", ] packages = [ {include = "gist"}, ] [tool.poetry.dependencies] python = "^3.6" requests = "^2.25.1" python-gnupg = "^0.4.7" [tool.poetry.dev-dependencies] pytest = "^6.2.2" responses = "^0.13.1" tox = "^3.23.0" tox-poetry = "^0.3.0" flake8 = "^3.9.0" flake8-black = "^0.2.1" flake8-bugbear = "^21.3.2" flake8-import-order = "^0.18.1" [tool.poetry.scripts] gist = "gist.client:main" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" ================================================ FILE: requirements-dev.txt ================================================ appdirs==1.4.4; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" atomicwrites==1.4.0; python_version >= "3.6" and python_full_version < "3.0.0" and sys_platform == "win32" or sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.4.0" attrs==21.2.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" black==20.8b1; python_version >= "3.6" certifi==2021.5.30; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" chardet==4.0.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" click==8.0.1; python_version >= "3.6" colorama==0.4.4; python_version >= "3.6" and python_full_version < "3.0.0" and sys_platform == "win32" and platform_system == "Windows" or sys_platform == "win32" and python_version >= "3.6" and python_full_version >= "3.5.0" and platform_system == "Windows" dataclasses==0.8; python_version >= "3.6" and python_version < "3.7" distlib==0.3.2; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" filelock==3.0.12; python_version >= "3" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3" flake8-black==0.2.1 flake8-bugbear==21.4.3; python_version >= "3.6" flake8-import-order==0.18.1 flake8==3.9.2; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") idna==2.10; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" importlib-metadata==4.4.0; python_version < "3.8" and python_version >= "3.6" and (python_version >= "3.6" and python_full_version < "3.0.0" and python_version < "3.8" or python_full_version >= "3.5.0" and python_version < "3.8" and python_version >= "3.6") and (python_version >= "3.6" and python_full_version < "3.0.0" and python_version < "3.8" or python_full_version >= "3.4.0" and python_version >= "3.6" and python_version < "3.8") importlib-resources==5.1.4; python_version >= "3.6" and python_full_version < "3.0.0" and python_version < "3.7" or python_full_version >= "3.5.0" and python_version < "3.7" and python_version >= "3.6" iniconfig==1.1.1; python_version >= "3.6" mccabe==0.6.1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" mypy-extensions==0.4.3; python_version >= "3.6" packaging==20.9; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" pathspec==0.8.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" pluggy==0.13.1; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" py==1.10.0; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" pycodestyle==2.7.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" pyflakes==2.3.1; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" pyparsing==2.4.7; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.4.0" and python_version >= "3.6" pytest==6.2.4; python_version >= "3.6" python-gnupg==0.4.7 regex==2021.4.4; python_version >= "3.6" requests==2.25.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") responses==0.13.3; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") six==1.16.0; python_version >= "3" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3" toml==0.10.2; python_version >= "3.6" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3.6" tox-poetry==0.3.0 tox==3.23.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") typed-ast==1.4.3; python_version >= "3.6" typing-extensions==3.10.0.0; python_version < "3.8" and python_version >= "3.6" urllib3==1.26.5; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version < "4" virtualenv==20.4.7; python_version >= "3" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version >= "3" zipp==3.4.1; python_version < "3.8" and python_version >= "3.6" and (python_version >= "3.6" and python_full_version < "3.0.0" and python_version < "3.7" or python_full_version >= "3.5.0" and python_version < "3.7" and python_version >= "3.6") ================================================ FILE: requirements.txt ================================================ certifi==2021.5.30; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" chardet==4.0.0; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" idna==2.10; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" python-gnupg==0.4.7 requests==2.25.1; (python_version >= "2.7" and python_full_version < "3.0.0") or (python_full_version >= "3.5.0") urllib3==1.26.5; python_version >= "2.7" and python_full_version < "3.0.0" or python_full_version >= "3.5.0" and python_version < "4" ================================================ FILE: share/gist-fzf.bash ================================================ #!/bin/bash # This function uses fzf to fuzzy match the output from 'gist list' in order to # enter a gist ID to commands that required IDs. __gist() { local curr=${COMP_WORDS[COMP_CWORD]} local cmd=${COMP_WORDS[1]} COMPREPLY=() case ${cmd} in edit|description|archive|files|content|clone|info) if (( ${COMP_CWORD} == 2 )); then tput smcup COMPREPLY=( $( gist list | fzf | cut -d" " -f1 ) ) tput rmcup fi ;; delete) tput smcup COMPREPLY=( $( gist list | fzf | cut -d" " -f1 ) ) tput rmcup ;; create|list|fork) ;; *) COMPREPLY=( $(compgen -W "edit description delete create fork archive files content clone list info" -- $curr) ) ;; esac } complete -F __gist gist ================================================ FILE: share/gist-fzsl.bash ================================================ #!/bin/bash # This function uses fzsl to fuzzy match the output from 'gist list' in order to # enter a gist ID to commands that required IDs. __gist() { local curr=${COMP_WORDS[COMP_CWORD]} local cmd=${COMP_WORDS[1]} COMPREPLY=() case ${cmd} in edit|description|archive|files|content|clone|info) if (( ${COMP_CWORD} == 2 )); then tput smcup COMPREPLY=( $( gist list | fzsl | cut -d" " -f1 ) ) tput rmcup fi ;; delete) tput smcup COMPREPLY=( $( gist list | fzsl | cut -d" " -f1 ) ) tput rmcup ;; create|list|fork) ;; *) COMPREPLY=( $(compgen -W "edit description delete create fork archive files content clone list info" -- $curr) ) ;; esac } complete -F __gist gist ================================================ FILE: share/gist.bash ================================================ #!/bin/bash __gist() { local curr=${COMP_WORDS[COMP_CWORD]} local cmd=${COMP_WORDS[1]} COMPREPLY=() case ${cmd} in edit|description|delete|archive|files|content|clone|list|info|fork) ;; create) if (( ${COMP_CWORD} >= 2 )); then compopt -o filenames COMPREPLY=( $(compgen -f -- ${curr}) ) fi ;; *) COMPREPLY=( $(compgen -W "edit description delete create fork archive files content clone list info" -- $curr) ) ;; esac } complete -F __gist gist ================================================ FILE: share/gist.fish ================================================ complete -c gist -f -a "create" -d "Creates a new gist" complete -c gist -f -a "edit" -d "Edit the files in your gist" complete -c gist -f -a "list" -d "Prints a list of your gists" complete -c gist -f -a "clone" -d "Clones a gist" complete -c gist -f -a "delete" -d "Deletes a gist from GitHub" complete -c gist -f -a "files" -d "Prints a list of the files in a gist" complete -c gist -f -a "archive" -d "Downloads a gist and creates a tarball" complete -c gist -f -a "content" -d "Prints the content of the gist to stdout" complete -c gist -f -a "info" -d "Prints detailed information about a gist" complete -c gist -f -a "version" -d "Prints the current version" complete -c gist -f -a "description" -d "Updates the description of a gist." ================================================ FILE: share/gist.zsh ================================================ #compdef gist _arguments \ '*:: :->subcmds' && return 0 local -a _first_arguments _first_arguments=( 'list:Print the list of your gists.' 'edit:Edit the files in your gist.' 'info:Print detailed information about the gist.' 'fork:Create a fork of the gist.' 'description:Update the description of the gist.' 'files:Print the list of files in a gist.' 'delete:Delete the gist from GitHub.' 'archive:Download the gist and create a tarball.' 'content:Print the contents of the gist to stdout.' 'create:Create a new gist.' 'clone:Clone the gist to a local repository.' ) if (( CURRENT == 1 )); then _describe -t commands "gist subcommand" _first_arguments return fi case "$words[1]" in (create) _arguments \ '--public[Create a public gist.]' \ '--encrypt[Encrypt the gist.]' \ ':description: :' \ '*:: :_files' ;; (content) _arguments \ '--decrypt[Decrypt the gist.]' ;; esac ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/conftest.py ================================================ import configparser import errno import shlex import subprocess import gist.client import gist.gist import gnupg import pytest def kill_gpg_agent(homedir): """Try to kill the spawned gpg-agent This is just a best-effort. With gpg-1.x, the agent will most likely not get started unless the user has done configuration to enforce it. With gpg-2.x, the agent will always be spawned as it is responsible for all handling of private keys. However, it was not until gpg-2.1.13 that gpgconf accepted the homedir argument. So: - gpg-1.x probably has nothing to kill and the return value doesn't matter - =gpg-2.1.13 will correctly kill the agent on shutdown. This could be improved, but 2.1.13 was released in mid-2016 and a quick survey of distros using gpg-2 shows they've all moved past that point. """ try: subprocess.call( shlex.split("gpgconf --homedir {} --kill gpg-agent".format(homedir)) ) except OSError as e: if e.errno != errno.ENOENT: raise @pytest.fixture(autouse=True) def disable_stdout_wrapper(monkeypatch): monkeypatch.setattr(gist.client, "wrap_stdout_for_unicode", lambda: None) @pytest.fixture(autouse=True) def editor(monkeypatch): monkeypatch.setenv("EDITOR", "gist-placeholder") @pytest.fixture def gist_api(): return gist.gist.GistAPI(token="f00") @pytest.fixture def gnupghome(): return "./tests/gnupg" @pytest.fixture def gpg(gnupghome): try: yield gnupg.GPG(gnupghome=gnupghome, use_agent=True) finally: kill_gpg_agent(gnupghome) @pytest.fixture def fingerprint(gpg): return gpg.list_keys()[0]["fingerprint"] @pytest.fixture def encrypt(gpg, fingerprint): def impl(text): data = text.encode("utf-8") crypt = gpg.encrypt(data, fingerprint) return crypt.data.decode("utf-8") return impl @pytest.fixture def decrypt(gpg): def impl(text): """Return the text as a decrypted string""" data = text.encode("utf-8") crypt = gpg.decrypt(data) return crypt.data.decode("utf-8") return impl @pytest.fixture def config(gnupghome, fingerprint): cfg = configparser.ConfigParser() cfg.add_section("gist") cfg.set("gist", "token", "f00") cfg.set("gist", "gnupg-homedir", gnupghome) cfg.set("gist", "gnupg-fingerprint", fingerprint) return cfg @pytest.fixture def gist_command(config, capsys): def impl(cmd): """Return stdout produce by the specified CLI command""" gist.client.main(argv=shlex.split(cmd), config=config) return capsys.readouterr().out.splitlines() return impl ================================================ FILE: tests/test_cli.py ================================================ import base64 import json import responses def b64encode(s): """Return the base64 encoding of a string To support string encodings other than ascii, the content of a gist needs to be uploaded in base64. Because python2.x and python3.x handle string differently, it is necessary to be explicit about passing a string into b64encode as bytes. This function handles the encoding of the string into bytes, and then decodes the resulting bytes into a UTF-8 string, which is returned. """ return base64.b64encode(s.encode("utf-8")).decode("utf-8") @responses.activate def test_list(editor, gist_command): message = list() expected_gists = list() for id in range(300): desc = "test-{}".format(id) public = id % 2 == 0 message.append( { "id": id, "description": desc, "public": public, } ) expected_gists.append("{} {} test-{}".format(id, "+" if public else "-", id)) responses.add( responses.GET, "https://api.github.com/gists", body=json.dumps(message), status=200, ) gists = gist_command("list") assert gists == expected_gists @responses.activate def test_content(editor, gist_command): responses.add( responses.GET, "https://api.github.com/gists/1", body=json.dumps( { "files": { "file-A.txt": { "filename": "file-A.txt", "content": b64encode("test-content-A"), }, "file-B.txt": { "filename": "file-B.txt", "content": b64encode("test-content-\u212C"), }, }, "description": "test-gist", "public": True, "id": 1, } ), status=200, ) lines = gist_command("content 1") assert "file-A.txt:" in lines assert "test-content-A" in lines assert "file-B.txt:" in lines assert "test-content-\u212c" in lines ================================================ FILE: tests/test_cli_parser.py ================================================ import contextlib import os import shlex import unittest.mock import gist.client import pytest @pytest.fixture(autouse=True) def suppress_stderr(): with open(os.devnull, "w") as fd: with contextlib.redirect_stderr(fd): yield @pytest.mark.parametrize( "command", [ "create 'desc'", "create --public 'desc'", "create --encrypt 'desc'", "create --public --encrypt 'desc'", "create --public --encrypt 'desc' --filename file1", "create --public --encrypt 'desc' file1 file2 file3", "create 'desc' --public", "create 'desc' --encrypt", "create 'desc' --public --encrypt", ], ) def test_cli_parser_gist_create_valid(monkeypatch, command, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_create) monkeypatch.setattr(gist.client, "handle_gist_create", handler) gist.client.main(argv=shlex.split(command), config=config) @pytest.mark.parametrize( "command", [ "create --public", "create --encrypt", "create 'desc' --encrypt file1", "create --public --encrypt 'desc' --filename file1 file2", ], ) def test_cli_parser_gist_create_invalid(monkeypatch, command, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_create) monkeypatch.setattr(gist.client, "handle_gist_create", handler) with pytest.raises(SystemExit): gist.client.main(argv=shlex.split(command), config=config) def test_cli_parser_gist_list_valid(monkeypatch, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_list) monkeypatch.setattr(gist.client, "handle_gist_list", handler) gist.client.main(argv=shlex.split("list"), config=config) def test_cli_parser_gist_list_invalid(monkeypatch, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_list) monkeypatch.setattr(gist.client, "handle_gist_list", handler) with pytest.raises(SystemExit): gist.client.main(argv=shlex.split("list --no-an-option"), config=config) @pytest.mark.parametrize("cmd", ["edit", "fork", "info", "files", "archive"]) def test_cli_parser_gist_generic_valid(monkeypatch, cmd, config): handler_name = "handle_gist_{}".format(cmd) handler_mock = unittest.mock.create_autospec(getattr(gist.client, handler_name)) monkeypatch.setattr(gist.client, handler_name, handler_mock) gist.client.main(argv=shlex.split("{} arg1".format(cmd)), config=config) @pytest.mark.parametrize("args", ["", "arg1 arg2"]) @pytest.mark.parametrize("cmd", ["edit", "fork", "info", "files", "archive"]) def test_cli_parser_gist_generic_invalid(monkeypatch, cmd, args, config): handler_name = "handle_gist_{}".format(cmd) handler_mock = unittest.mock.create_autospec(handler_name) monkeypatch.setattr(gist.client, handler_name, handler_mock) with pytest.raises(SystemExit): gist.client.main(argv=shlex.split("{} {}".format(cmd, args)), config=config) @pytest.mark.parametrize("command", ["description id desc", "description id 'desc'"]) def test_cli_parser_gist_description_valid(monkeypatch, command, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_description) monkeypatch.setattr(gist.client, "handle_gist_description", handler) gist.client.main(argv=shlex.split(command), config=config) @pytest.mark.parametrize( "command", [ "description id", "description id foo bar", ], ) def test_cli_parser_gist_description_invalid(monkeypatch, command, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_description) monkeypatch.setattr(gist.client, "handle_gist_description", handler) with pytest.raises(SystemExit): gist.client.main(argv=shlex.split(command), config=config) @pytest.mark.parametrize( "command", [ "content id", "content id --decrypt", "content id file1 --decrypt", "content --decrypt id", "content --decrypt id file1", ], ) def test_cli_parser_gist_content_valid(monkeypatch, command, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_content) monkeypatch.setattr(gist.client, "handle_gist_content", handler) gist.client.main(argv=shlex.split(command), config=config) @pytest.mark.parametrize( "command", [ "content", "content --decrypt", "content id file1 file2 --decrypt", ], ) def test_cli_parser_gist_content_invalid(monkeypatch, command, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_content) monkeypatch.setattr(gist.client, "handle_gist_content", handler) with pytest.raises(SystemExit): gist.client.main(argv=shlex.split(command), config=config) @pytest.mark.parametrize( "command", [ "clone id", "clone id name", "clone id 'long name'", ], ) def test_cli_parser_gist_clone_valid(monkeypatch, command, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_clone) monkeypatch.setattr(gist.client, "handle_gist_clone", handler) gist.client.main(argv=shlex.split(command), config=config) @pytest.mark.parametrize("command", ["clone", "clone id name1 name2"]) def test_cli_parser_gist_clone_invalid(monkeypatch, command, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_clone) monkeypatch.setattr(gist.client, "handle_gist_clone", handler) with pytest.raises(SystemExit): gist.client.main(argv=shlex.split(command), config=config) def test_cli_parser_gist_version_valid(monkeypatch, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_version) monkeypatch.setattr(gist.client, "handle_gist_version", handler) gist.client.main(argv=shlex.split("version"), config=config) def test_cli_parser_gist_version_invalid(monkeypatch, config): handler = unittest.mock.create_autospec(gist.client.handle_gist_version) monkeypatch.setattr(gist.client, "handle_gist_version", handler) with pytest.raises(SystemExit): gist.client.main(argv=shlex.split("version arg"), config=config) ================================================ FILE: tests/test_config.py ================================================ import configparser import gist import pytest @pytest.fixture def config(): cfg = configparser.ConfigParser() cfg.add_section("gist") return cfg def test_get_value_from_command(): """ Ensure that values which start with ``!`` are treated as commands and return the string printed to stdout by the command, otherwise ensure that the value passed to the function is returned. """ assert "magic token" == gist.client.get_value_from_command('!echo "\nmagic token"') assert "magic token" == gist.client.get_value_from_command(' !echo "magic token\n"') assert "magic token" == gist.client.get_value_from_command("magic token") def test_get_personal_access_token_missing(config): with pytest.raises(gist.client.GistMissingTokenError): gist.client.get_personal_access_token(config) @pytest.mark.parametrize("token", ["", " "]) def test_get_personal_access_token_empty(config, token): config.set("gist", "token", token) with pytest.raises(gist.client.GistEmptyTokenError): gist.client.get_personal_access_token(config) @pytest.mark.parametrize("token", [" 123 ", "123abcABC0987"]) def test_get_personal_access_token_valid(config, token): config.set("gist", "token", token) gist.client.get_personal_access_token(config) ================================================ FILE: tests/test_gist.py ================================================ import base64 import json import re import sys import responses def b64encode(s): """Return the base64 encoding of a string To support string encodings other than ascii, the content of a gist needs to be uploaded in base64. Because python2.x and python3.x handle string differently, it is necessary to be explicit about passing a string into b64encode as bytes. This function handles the encoding of the string into bytes, and then decodes the resulting bytes into a UTF-8 string, which is returned. """ return base64.b64encode(s.encode("utf-8")).decode("utf-8") @responses.activate def test_list(gist_api): responses.add( responses.GET, "https://api.github.com/gists", body=json.dumps( [ { "id": 1, "description": "test-desc-A", "public": True, }, { "id": 2, "description": "test-desc-\u212C", "public": False, }, ] ), status=200, ) gists = gist_api.list() gistA = gists[0] gistB = gists[1] assert gistA.id == 1 assert gistA.desc == "test-desc-A" assert gistA.public assert gistB.id == 2 assert gistB.desc == "test-desc-\u212C" assert not gistB.public @responses.activate def test_list_empty(gist_api): responses.add( responses.GET, "https://api.github.com/gists", body="", status=200, ) gists = gist_api.list() assert len(gists) == 0 @responses.activate def test_content(gist_api): responses.add( responses.GET, "https://api.github.com/gists/1", body=json.dumps( { "files": { "file-A.txt": { "filename": "file-A.txt", "content": b64encode("test-content-A"), }, "file-B.txt": { "filename": "file-B.txt", "content": b64encode("test-content-\u212C"), }, }, "description": "test-gist", "public": True, "id": 1, } ), status=200, ) content = gist_api.content("1") assert len(content) == 2 assert "file-A.txt" in content assert "file-B.txt" in content assert content["file-A.txt"] == "test-content-A" assert content["file-B.txt"] == "test-content-\u212C" @responses.activate def test_create(gist_api): def request_handler(request): data = json.loads(request.body) assert len(data["files"]) == 2 assert "test-file-A" in data["files"] content = {k: v["content"] for k, v in data["files"].items()} assert content["test-file-A"] == "test-content-A" assert content["test-file-B"] == "test-content-\u212C" status = 200 headers = {} body = json.dumps({"html_url": "https://gist.github.com/gists/1"}) return status, headers, body responses.add_callback( responses.POST, "https://api.github.com/gists", callback=request_handler, content_type="application/json", ) public = True desc = "test-desc" files = { "test-file-A": {"content": "test-content-A"}, "test-file-B": {"content": "test-content-\u212C"}, } gist_api.create(desc, files, public) @responses.activate def test_gnupg_create_from_file(monkeypatch, decrypt, gist_command, tmp_path): """ This test checks that the content from a gist created from a file is properly encrypted. """ # This is a work-around for testing with github actions. For some reason, stdin is # no a TTY when run from there when it is normally. Hopefull, I can find a bettter # solution in the future. monkeypatch.setattr(sys.stdin, "isatty", lambda: True) def request_handler(request): # Decrypt the content of the request and check that it matches the # original content. body = json.loads(request.body) data = list(body["files"].values()) text = decrypt(data[0]["content"]) assert u"test-content-\u212C" in text status = 200 headers = {} body = json.dumps({"html_url": "https://gist.github.com/gists/1"}) return status, headers, body responses.add_callback( responses.POST, "https://api.github.com/gists", callback=request_handler, content_type="application/json", ) # Create a temporary file and write a test message to it filename = tmp_path / "gist-test-file.txt" with open(filename, "w", encoding="utf-8") as fp: fp.write(u"test-content-\u212C\n") # It is important to escape the path here to ensure the separators are not stripped # on Windows. cmd = r'create --encrypt "test-desc" {}'.format(re.escape(str(filename))) gist_command(cmd) @responses.activate def test_gnupg_content(encrypt, gist_command): """ When encrypted content is received, check to make sure that it can be properly decrypted. """ def b64encrypt(content): return b64encode(encrypt(content)) responses.add( responses.GET, "https://api.github.com/gists/1", body=json.dumps( { "files": { "file-A.txt": { "filename": "file-A.txt", "content": b64encrypt(u"test-content-A"), }, "file-B.txt": { "filename": "file-B.txt", "content": b64encrypt(u"test-content-\u212C"), }, }, "description": "test-gist", "public": True, "id": 1, } ), status=200, ) lines = gist_command("content 1 --decrypt") assert u"file-A.txt (decrypted):" in lines assert u"test-content-A" in lines assert u"file-B.txt (decrypted):" in lines assert u"test-content-\u212C" in lines def test_gnupg(encrypt, decrypt): """ Make sure that the basic mechanism put in place for testing the encryption used in gist works as expected. """ text = u"this is a message \u212C" cypher = encrypt(text) plain = decrypt(cypher) assert text != cypher assert text == plain ================================================ FILE: tox.ini ================================================ [tox] envlist = py3{6,7,8,9}.* [testenv] whitelist_externals = make commands = make lint test