├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .readthedocs.yml ├── LICENSE ├── Makefile ├── README.rst ├── docs ├── Makefile ├── _static │ └── .gitkeep ├── _templates │ └── .gitkeep ├── changelog.rst ├── conf.py ├── developer │ ├── gpg.rst │ ├── pypirc │ ├── releasing.rst │ └── testing.rst ├── index.rst └── user │ ├── client.ini │ ├── configuration.rst │ ├── installation.rst │ └── usage.rst ├── pylintrc ├── requirements ├── ci.txt ├── dev.txt ├── docs.txt └── tests.txt ├── setup.cfg ├── setup.py ├── shaarli_client ├── __init__.py ├── client │ ├── __init__.py │ └── v1.py ├── config.py ├── main.py └── utils.py ├── tests ├── __init__.py ├── client │ ├── __init__.py │ └── test_v1.py ├── test_config.py └── test_utils.py └── tox.ini /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: python-shaarli-client CI 2 | on: [push, pull_request] 3 | jobs: 4 | tox: 5 | runs-on: ubuntu-20.04 6 | strategy: 7 | matrix: 8 | python-version: ['3.6', '3.7', '3.8', '3.9'] 9 | name: python 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v2 13 | - name: Setup python ${{ matrix.python-version }} 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: ${{ matrix.python-version }} 17 | - name: Install requirements 18 | run: pip install -r requirements/ci.txt 19 | - name: Run tests 20 | env: 21 | TOXENV: py${{ matrix.python-version }} 22 | run: tox 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .pytest_cache/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *,cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # IPython Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # dotenv 80 | .env 81 | 82 | # virtualenv 83 | venv/ 84 | ENV/ 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | 89 | # Rope project settings 90 | .ropeproject 91 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | # .readthedocs.yml 2 | # Read the Docs configuration file 3 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 4 | 5 | # Required 6 | version: 2 7 | 8 | # Build documentation in the "docs/" directory with Sphinx 9 | sphinx: 10 | configuration: docs/conf.py 11 | builder: html 12 | 13 | build: 14 | os: ubuntu-22.04 15 | tools: 16 | python: "3.11" 17 | commands: 18 | - pip install sphinx-rtd-theme 19 | - sphinx-build -b html -c docs/ docs/ _readthedocs/html/ 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 The Shaarli Community 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: tests clean dist 2 | 3 | .PHONY: tests # run test suite 4 | tests: 5 | pip install -r requirements/ci.txt 6 | tox 7 | 8 | .PHONY: clean # clean files generated by tests/package build process 9 | clean: 10 | @rm -rf build dist 11 | @find . -name "*.pyc" -delete 12 | @git clean -xdf 13 | 14 | ##### 15 | 16 | .PHONY: dist # build pip packages 17 | dist: sdist bdist_wheel 18 | 19 | .PHONY: sdist # build source distribution for the pip package 20 | sdist: 21 | @python setup.py sdist 22 | 23 | .PHONY: sdist # build binary (wheel) distribution for the pip package 24 | bdist_wheel: 25 | @python setup.py bdist_wheel 26 | 27 | ##### 28 | 29 | .PHONY: install # install the python package 30 | install: 31 | @python setup.py install 32 | 33 | .PHONY: release # upload source/binary distributions of the package to pypi 34 | release: clean dist 35 | @twine upload dist/* -s -i $(IDENTITY) 36 | 37 | .PHONY: test_release # TODO undocumented 38 | test_release: clean dist 39 | @twine upload dist/* -r testpypi --skip-existing -s -i $(IDENTITY) 40 | 41 | .PHONY: docs # build sphinx documentation 42 | docs: clean 43 | @tox -r -e docs 44 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | python-shaarli-client 2 | ===================== 3 | 4 | .. image:: https://github.com/shaarli/python-shaarli-client/actions/workflows/ci.yml/badge.svg 5 | :target: https://github.com/shaarli/python-shaarli-client/actions 6 | :alt: Github Actions build status 7 | 8 | .. image:: https://readthedocs.org/projects/python-shaarli-client/badge/?version=latest 9 | :target: http://python-shaarli-client.readthedocs.org/en/latest/?badge=latest 10 | :alt: Documentation Status 11 | 12 | A Python 3 Command-Line Interface to interact with a Shaarli instance. 13 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SPHINXPROJ = shaarli-client 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/_static/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaarli/python-shaarli-client/e8393e4e2dbcfc9a2eac0752b95ed9f1d058138f/docs/_static/.gitkeep -------------------------------------------------------------------------------- /docs/_templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shaarli/python-shaarli-client/e8393e4e2dbcfc9a2eac0752b95ed9f1d058138f/docs/_templates/.gitkeep -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Change Log 2 | ========== 3 | 4 | All notable changes to this project will be documented in this file. 5 | 6 | The format is based on `Keep a Changelog`_ and this project adheres to 7 | `Semantic Versioning`_. 8 | 9 | .. _Keep A Changelog: http://keepachangelog.com/ 10 | .. _Semantic Versioning: http://semver.org/ 11 | 12 | 13 | `v0.5.0 `_ - 2022-07-26 14 | --------------------------------------------------------------------------------------------- 15 | 16 | **Added:** 17 | 18 | * Add ``delete-link`` command (delete a link by ID) 19 | 20 | 21 | **Changed:** 22 | 23 | * Update test tooling and documentation 24 | 25 | 26 | **Fixed:** 27 | 28 | * Fix ``--insecure`` option for non-GET requests 29 | 30 | **Security:** 31 | 32 | * Update `PyJWT `_ to 2.4.0 33 | 34 | 35 | `v0.4.1 `_ - 2021-05-13 36 | --------------------------------------------------------------------------------------------- 37 | 38 | **Added:** 39 | 40 | * Add support for Python 3.7, 3.8 and 3.9 41 | 42 | 43 | **Changed:** 44 | 45 | * Bump project and test requirements 46 | * Update test tooling and documentation 47 | 48 | 49 | **Removed:** 50 | 51 | * Drop support for Python 3.4 and 3.5 52 | 53 | 54 | **Security:** 55 | 56 | * Rework JWT usage without the unmaintained requests-jwt library 57 | 58 | 59 | `v0.4.0 `_ - 2020-01-09 60 | --------------------------------------------------------------------------------------------- 61 | 62 | **Added:** 63 | 64 | * CLI: 65 | 66 | * Add support for ``--insecure`` option (bypass SSL certificate verification) 67 | 68 | 69 | `v0.3.0 `_ - 2019-02-23 70 | --------------------------------------------------------------------------------------------- 71 | 72 | **Added:** 73 | 74 | * CLI: 75 | 76 | * Add support for endpoint resource(s) 77 | 78 | * REST API client: 79 | 80 | * ``PUT api/v1/links/`` 81 | 82 | 83 | **Fixed:** 84 | 85 | * Use requests-jwt < 0.5 86 | * Fix `POST /link` endpoint name 87 | 88 | 89 | `v0.2.0 `_ - 2017-04-09 90 | --------------------------------------------------------------------------------------------- 91 | 92 | **Added:** 93 | 94 | * Add client parameter checks and error handling 95 | * Read instance information from a configuration file 96 | * REST API client: 97 | 98 | * ``POST api/v1/links`` 99 | 100 | **Changed:** 101 | 102 | * CLI: 103 | 104 | * rename ``--output`` to ``--format`` 105 | * default to 'pprint' output format 106 | * improve endpoint-specific parser argument generation 107 | * improve exception handling and logging 108 | 109 | 110 | `v0.1.0 `_ - 2017-03-12 111 | --------------------------------------------------------------------------------------------- 112 | 113 | **Added:** 114 | 115 | * Python project structure 116 | * Packaging metadata 117 | * Code quality checking (lint) 118 | * Test coverage 119 | * Sphinx documentation: 120 | 121 | * user - installation, usage 122 | * developer - testing, releasing 123 | 124 | * Makefile 125 | * Tox configuration 126 | * Travis CI configuration 127 | * REST API client: 128 | 129 | * ``GET /api/v1/info`` 130 | * ``GET /api/v1/links`` 131 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | """shaarli-client Sphinx build configuration""" 4 | import datetime 5 | import os 6 | import sys 7 | 8 | # Read metadata from the package itself 9 | sys.path.insert(0, os.path.abspath('..')) 10 | from shaarli_client import __author__, __brief__, __title__, __version__ 11 | 12 | 13 | # -- General configuration ------------------------------------------------ 14 | 15 | # If your documentation needs a minimal Sphinx version, state it here. 16 | # 17 | # needs_sphinx = '1.0' 18 | 19 | # Add any Sphinx extension module names here, as strings. They can be 20 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 21 | # ones. 22 | extensions = ['sphinx.ext.autodoc'] 23 | 24 | # Add any paths that contain templates here, relative to this directory. 25 | templates_path = ['_templates'] 26 | 27 | # The suffix(es) of source filenames. 28 | # You can specify multiple suffix as a list of string: 29 | # 30 | # source_suffix = ['.rst', '.md'] 31 | source_suffix = '.rst' 32 | 33 | # The master toctree document. 34 | master_doc = 'index' 35 | 36 | # General information about the project. 37 | author = __author__ 38 | brief = __brief__ 39 | project = __title__.title() 40 | year = datetime.date.today().year 41 | copyright = '{year}, {author}'.format(year=year, author=author) 42 | 43 | # The version info for the project you're documenting, acts as replacement for 44 | # |version| and |release|, also used in various other places throughout the 45 | # built documents. 46 | # 47 | # The short X.Y version. 48 | version = __version__ 49 | # The full version, including alpha/beta/rc tags. 50 | release = __version__ 51 | 52 | # The language for content autogenerated by Sphinx. Refer to documentation 53 | # for a list of supported languages. 54 | # 55 | # This is also used if you do content translation via gettext catalogs. 56 | # Usually you set "language" from the command line for these cases. 57 | language = None 58 | 59 | # List of patterns, relative to source directory, that match files and 60 | # directories to ignore when looking for source files. 61 | # This patterns also effect to html_static_path and html_extra_path 62 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 63 | 64 | # The name of the Pygments (syntax highlighting) style to use. 65 | pygments_style = 'sphinx' 66 | 67 | # If true, `todo` and `todoList` produce output, else they produce nothing. 68 | todo_include_todos = False 69 | 70 | 71 | # -- Options for HTML output ---------------------------------------------- 72 | 73 | # The theme to use for HTML and HTML Help pages. See the documentation for 74 | # a list of builtin themes. 75 | # 76 | html_theme = 'sphinx_rtd_theme' 77 | 78 | # Theme options are theme-specific and customize the look and feel of a theme 79 | # further. For a list of options available for each theme, see the 80 | # documentation. 81 | # 82 | # html_theme_options = {} 83 | 84 | # Add any paths that contain custom static files (such as style sheets) here, 85 | # relative to this directory. They are copied after the builtin static files, 86 | # so a file named "default.css" will overwrite the builtin "default.css". 87 | html_static_path = ['_static'] 88 | 89 | 90 | # -- Options for HTMLHelp output ------------------------------------------ 91 | 92 | # Output file base name for HTML help builder. 93 | htmlhelp_basename = 'shaarli-clientdoc' 94 | 95 | 96 | # -- Options for LaTeX output --------------------------------------------- 97 | 98 | latex_elements = { 99 | # The paper size ('letterpaper' or 'a4paper'). 100 | # 101 | # 'papersize': 'letterpaper', 102 | 103 | # The font size ('10pt', '11pt' or '12pt'). 104 | # 105 | # 'pointsize': '10pt', 106 | 107 | # Additional stuff for the LaTeX preamble. 108 | # 109 | # 'preamble': '', 110 | 111 | # Latex figure (float) alignment 112 | # 113 | # 'figure_align': 'htbp', 114 | } 115 | 116 | # Grouping the document tree into LaTeX files. List of tuples 117 | # (source start file, target name, title, 118 | # author, documentclass [howto, manual, or own class]). 119 | latex_documents = [ 120 | (master_doc, 'shaarli-client.tex', 'shaarli-client Documentation', 121 | author, 'manual'), 122 | ] 123 | 124 | 125 | # -- Options for manual page output --------------------------------------- 126 | 127 | # One entry per manual page. List of tuples 128 | # (source start file, name, description, authors, manual section). 129 | man_pages = [ 130 | (master_doc, project, 'shaarli-client Documentation', 131 | [author], 1) 132 | ] 133 | 134 | 135 | # -- Options for Texinfo output ------------------------------------------- 136 | 137 | # Grouping the document tree into Texinfo files. List of tuples 138 | # (source start file, target name, title, author, 139 | # dir menu entry, description, category) 140 | texinfo_documents = [ 141 | (master_doc, 'shaarli-client', 'shaarli-client Documentation', 142 | author, project, brief, 143 | 'Miscellaneous'), 144 | ] 145 | -------------------------------------------------------------------------------- /docs/developer/gpg.rst: -------------------------------------------------------------------------------- 1 | GnuPG 2 | ===== 3 | 4 | Introduction 5 | ------------ 6 | 7 | PGP and GPG 8 | ~~~~~~~~~~~ 9 | 10 | `Gnu Privacy Guard`_ (GnuPG) is an Open Source implementation of the 11 | `Pretty Good Privacy`_ (OpenPGP) specification. 12 | Its main purposes are digital authentication, signature and encryption. 13 | 14 | It is often used by the `FLOSS`_ community to verify: 15 | 16 | * Linux package signatures: Debian `SecureApt`_, ArchLinux `Master Keys`_ 17 | * `SCM`_ releases & maintainer identity 18 | 19 | .. _FLOSS: https://en.wikipedia.org/wiki/Free_and_open-source_software 20 | .. _Gnu Privacy Guard: https://gnupg.org/ 21 | .. _Master Keys: https://www.archlinux.org/master-keys/ 22 | .. _Pretty Good Privacy: https://en.wikipedia.org/wiki/Pretty_Good_Privacy#OpenPGP 23 | .. _SCM: https://en.wikipedia.org/wiki/Revision_control 24 | .. _SecureApt: https://wiki.debian.org/SecureApt 25 | 26 | Trust 27 | ~~~~~ 28 | 29 | To quote Phil Pennock, the author of the `SKS`_ key `server `_: 30 | 31 | .. pull-quote:: 32 | 33 | You MUST understand that presence of data in the keyserver (pools) in no way connotes trust. 34 | Anyone can generate a key, with any name or email address, and upload it. 35 | All security and trust comes from evaluating security at the “object level”, 36 | via PGP Web-Of-Trust signatures. 37 | This keyserver makes it possible to retrieve keys, looking them up via various indices, 38 | but the collection of keys in this public pool is KNOWN to contain malicious 39 | and fraudulent keys. 40 | It is the common expectation of server operators that users understand this 41 | and use software which, like all known common OpenPGP implementations, 42 | evaluates trust accordingly. 43 | This expectation is so common that it is not normally explicitly stated. 44 | 45 | Trust can be gained by having your key signed by other people 46 | (and signing their keys back, too :-) ), for instance during `key signing parties`_: 47 | 48 | * `The Keysigning Party HOWTO 49 | `_ 50 | * `Web of Trust 51 | `_ 52 | 53 | .. _key signing parties: https://en.wikipedia.org/wiki/Key_signing_party 54 | .. _SKS: https://bitbucket.org/skskeyserver/sks-keyserver/wiki/Home 55 | 56 | Generate a GPG key 57 | ------------------ 58 | 59 | * `Generating a GPG key for Git tagging `_ (StackOverflow) 60 | * `Generating a GPG key `_ (GitHub) 61 | 62 | ``gpg`` - provide identity information 63 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 64 | 65 | .. code-block:: bash 66 | 67 | $ gpg --gen-key 68 | 69 | gpg (GnuPG) 2.1.6; Copyright (C) 2015 Free Software Foundation, Inc. 70 | This is free software: you are free to change and redistribute it. 71 | There is NO WARRANTY, to the extent permitted by law. 72 | 73 | Note: Use "gpg2 --full-gen-key" for a full featured key generation dialog. 74 | 75 | GnuPG needs to construct a user ID to identify your key. 76 | 77 | Real name: Marvin the Paranoid Android 78 | Email address: marvin@h2g2.net 79 | You selected this USER-ID: 80 | "Marvin the Paranoid Android " 81 | 82 | Change (N)ame, (E)mail, or (O)kay/(Q)uit? o 83 | We need to generate a lot of random bytes. It is a good idea to perform 84 | some other action (type on the keyboard, move the mouse, utilize the 85 | disks) during the prime generation; this gives the random number 86 | generator a better chance to gain enough entropy. 87 | 88 | ``gpg`` - entropy interlude 89 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 90 | 91 | At this point, you will: 92 | 93 | * be prompted for a secure password to protect your key 94 | (the input method will depend on your Desktop Environment and configuration) 95 | * be asked to use your machine's input devices (mouse, keyboard, etc.) 96 | to generate random entropy; this step *may take some time* 97 | 98 | ``gpg`` - key creation confirmation 99 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 100 | 101 | .. code-block:: bash 102 | 103 | gpg: key A9D53A3E marked as ultimately trusted 104 | public and secret key created and signed. 105 | 106 | gpg: checking the trustdb 107 | gpg: 3 marginal(s) needed, 1 complete(s) needed, PGP trust model 108 | gpg: depth: 0 valid: 2 signed: 0 trust: 0-, 0q, 0n, 0m, 0f, 2u 109 | pub rsa2048/A9D53A3E 2015-07-31 110 | Key fingerprint = AF2A 5381 E54B 2FD2 14C4 A9A3 0E35 ACA4 A9D5 3A3E 111 | uid [ultimate] Marvin the Paranoid Android 112 | sub rsa2048/8C0EACF1 2015-07-31 113 | 114 | ``gpg`` - submit your public key to a PGP server 115 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 116 | 117 | .. code-block:: bash 118 | 119 | $ gpg --keyserver pgp.mit.edu --send-keys A9D53A3E 120 | gpg: sending key A9D53A3E to hkp server pgp.mit.edu 121 | -------------------------------------------------------------------------------- /docs/developer/pypirc: -------------------------------------------------------------------------------- 1 | [distutils] 2 | index-servers= 3 | pypi 4 | testpypi 5 | 6 | [pypi] 7 | repository = https://upload.pypi.org/legacy/ 8 | username = 9 | 10 | [testpypi] 11 | repository = https://test.pypi.org/legacy/ 12 | username = 13 | password = 14 | -------------------------------------------------------------------------------- /docs/developer/releasing.rst: -------------------------------------------------------------------------------- 1 | Releasing 2 | ========= 3 | 4 | Reference: 5 | 6 | * `Python Packaging User Guide`_ 7 | 8 | * `Packaging and Distributing Projects`_ 9 | 10 | * `TestPyPI Configuration`_ 11 | 12 | Environment and requirements 13 | ---------------------------- 14 | 15 | `twine`_ is used to register Python projects to `PyPI`_ and upload release artifacts: 16 | 17 | * ``PKG-INFO``: project description and metadata defined in ``setup.py`` 18 | * ``sdist``: source distribution tarball 19 | * ``wheel``: binary release that can be platform- and interpreter- dependent 20 | 21 | Development libraries need to be installed to build the project and upload artifacts 22 | (see :doc:`testing`): 23 | 24 | .. code-block:: bash 25 | 26 | (shaarli) $ pip install -r requirements/dev.txt 27 | 28 | PyPI and TestPyPI configuration 29 | ------------------------------- 30 | 31 | .. danger:: 32 | 33 | Once uploaded, artifacts cannot be overwritten. If something goes wrong while 34 | releasing artifacts, you will need to bump the release version code and issue 35 | a new release. 36 | 37 | It is safer to test the release process on `TestPyPI`_ first; it provides 38 | a sandbox to experiment with project registration and upload. 39 | 40 | ``~/.pypirc`` 41 | ~~~~~~~~~~~~~ 42 | 43 | .. literalinclude:: pypirc 44 | :language: ini 45 | 46 | 47 | Releasing ``shaarli-client`` 48 | ---------------------------- 49 | 50 | Checklist 51 | ~~~~~~~~~ 52 | 53 | * install Python dependencies 54 | * setup PyPI and TestPyPI: 55 | 56 | * create an account on both servers 57 | * edit ``~/.pypirc`` 58 | * register the project on both servers 59 | 60 | * get a :doc:`gpg` key to sign the artifacts 61 | * double check project binaries and metadata 62 | * tag the new release 63 | * build and upload the release on TestPyPI 64 | * build and upload the release on PyPI 65 | 66 | .. tip:: 67 | 68 | A ``Makefile`` is provided for convenience, and allows to build, sign 69 | and upload artifacts on both `PyPI`_ and `TestPyPI`_. 70 | 71 | TestPyPI 72 | ~~~~~~~~ 73 | 74 | .. code-block:: bash 75 | 76 | (shaarli) $ export IDENTITY= 77 | (shaarli) $ make test_release 78 | 79 | PyPI 80 | ~~~~ 81 | 82 | .. code-block:: bash 83 | 84 | (shaarli) $ export IDENTITY= 85 | (shaarli) $ make release 86 | 87 | 88 | .. _Packaging and Distributing Projects: https://packaging.python.org/distributing/ 89 | .. _PyPI: https://pypi.org/ 90 | .. _Python Packaging User Guide: https://packaging.python.org 91 | .. _TestPyPI: https://test.pypi.org/ 92 | .. _TestPyPI Configuration: https://packaging.python.org/en/latest/guides/using-testpypi/ 93 | .. _twine: https://pypi.python.org/pypi/twine 94 | -------------------------------------------------------------------------------- /docs/developer/testing.rst: -------------------------------------------------------------------------------- 1 | Testing 2 | ======= 3 | 4 | See also: 5 | 6 | * :doc:`../user/installation` 7 | 8 | Environment and requirements 9 | ---------------------------- 10 | 11 | `Tox`_ is used to manage test `virtualenvs`_, and is the only tool needed to run 12 | static analysis and unitary tests, as it will create the appropriate testing 13 | virtualenvs on-the-fly. 14 | 15 | .. code-block:: bash 16 | 17 | (shaarli) $ pip install -r requirements/ci.txt 18 | 19 | 20 | Nevertheless, in case you want to install *test*, *development* and *documentation* 21 | dependencies, e.g. for editor integration or local debugging: 22 | 23 | .. code-block:: bash 24 | 25 | (shaarli) $ pip install -r requirements/dev.txt 26 | 27 | Tools 28 | ----- 29 | 30 | The documentation is written in `reStructuredText`_, using the `Sphinx`_ generator. 31 | 32 | Coding style is checked using tools provided by the `Python Code Quality Authority`_: 33 | 34 | * `isort`_: check import ordering and formatting 35 | * `pycodestyle`_: Python syntax and coding style (see `PEP8`_) 36 | * `pydocstyle`_: docstring formatting (see `PEP257`_) 37 | * `pylint`_: syntax checking using predefined heuristics 38 | 39 | Tests are run using the `pytest`_ test framework/harness, with the following plugins: 40 | 41 | * `pytest-pylint`_: `pylint`_ integration 42 | * `pytest-cov`_: `coverage`_ integration 43 | 44 | Running the tests 45 | ----------------- 46 | 47 | To renew test virtualenvs, run all tests and generate the documentation: 48 | 49 | .. code-block:: bash 50 | 51 | $ tox -r 52 | 53 | To run specific tests without renewing the corresponding virtualenvs: 54 | 55 | .. code-block:: bash 56 | 57 | $ tox -e py34 -e py36 58 | 59 | To run specific tests and renew the corresponding virtualenv: 60 | 61 | .. code-block:: bash 62 | 63 | $ tox -r py35 64 | 65 | .. _coverage: https://coverage.readthedocs.io/en/latest/ 66 | .. _isort: https://github.com/timothycrosley/isort#readme 67 | .. _PEP8: http://pep8.readthedocs.org 68 | .. _PEP257: http://pep257.readthedocs.org 69 | .. _pycodestyle: http://pycodestyle.pycqa.org/en/latest/ 70 | .. _pydocstyle: http://www.pydocstyle.org/en/latest/ 71 | .. _pylint: http://www.pylint.org/ 72 | .. _pytest: http://docs.pytest.org/en/latest/ 73 | .. _pytest-cov: https://pytest-cov.readthedocs.io/en/latest/ 74 | .. _pytest-pylint: https://github.com/carsongee/pytest-pylint 75 | .. _Python Code Quality Authority: http://meta.pycqa.org/en/latest/ 76 | .. _reStructuredtext: http://www.sphinx-doc.org/en/stable/rest.html 77 | .. _Sphinx: http://www.sphinx-doc.org/en/stable/ 78 | .. _Tox: http://tox.readthedocs.org/en/latest/ 79 | .. _virtualenvs: https://virtualenv.pypa.io/en/stable/ 80 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | shaarli-client - documentation 2 | ============================== 3 | 4 | Command-line interface (CLI) to interact with a `Shaarli`_ instance. 5 | 6 | .. _Shaarli: https://github.com/shaarli/Shaarli 7 | 8 | .. toctree:: 9 | :maxdepth: 2 10 | :caption: User Documentation 11 | 12 | user/installation 13 | user/configuration 14 | user/usage 15 | changelog 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | :caption: Developer Documentation 20 | 21 | developer/testing 22 | developer/releasing 23 | developer/gpg 24 | 25 | 26 | Indices and tables 27 | ================== 28 | 29 | * :ref:`genindex` 30 | * :ref:`modindex` 31 | * :ref:`search` 32 | -------------------------------------------------------------------------------- /docs/user/client.ini: -------------------------------------------------------------------------------- 1 | [shaarli] 2 | url = https://host.tld/shaarli 3 | secret = s3kr37! 4 | 5 | [shaarli:shaaplin] 6 | url = https://shaarli.shaapl.in 7 | secret = m0d3rn71m3s 8 | 9 | [shaarli:dev] 10 | url = http://localhost/shaarli 11 | secret = asdf1234 12 | -------------------------------------------------------------------------------- /docs/user/configuration.rst: -------------------------------------------------------------------------------- 1 | Configuration 2 | ============= 3 | 4 | ``shaarli-client`` loads information about Shaarli instances from a 5 | configuration file, located at: 6 | 7 | * ``~/.config/shaarli/client.ini`` (recommended) 8 | * ``~/.shaarli_client.ini`` 9 | * ``shaarli_client.ini`` (in the current directory) 10 | * user-specified location, using the ``-c``/``--config`` flag 11 | 12 | Several Shaarli instances can be configured: 13 | 14 | ``[shaarli]`` 15 | the default instance 16 | ``[shaarli:]`` 17 | an additional instance that can be selected by passing the ``-i`` flag: 18 | ``$ shaarli -i my-other-instance get-info`` 19 | 20 | Example 21 | ------- 22 | 23 | .. literalinclude:: client.ini 24 | :language: ini 25 | -------------------------------------------------------------------------------- /docs/user/installation.rst: -------------------------------------------------------------------------------- 1 | Installation 2 | ============ 3 | 4 | ``shaarli-client`` is compatible with `Python `_ 3.4 5 | and above and has been tested on Linux. 6 | 7 | From the Python Package Index (PyPI) 8 | ------------------------------------ 9 | 10 | The preferred way of installing ``shaarli-client`` is within a Python `virtualenv`_; 11 | you might want to use a wrapper such as `virtualenvwrapper`_ or `pew`_ for convenience. 12 | 13 | .. _virtualenv: http://docs.python-guide.org/en/latest/dev/virtualenvs/ 14 | .. _virtualenvwrapper: https://virtualenvwrapper.readthedocs.io/en/latest/ 15 | .. _pew: https://github.com/berdario/pew 16 | 17 | Here is an example using a Python 3.5 interpreter: 18 | 19 | .. code-block:: bash 20 | 21 | # create a new 'shaarli' virtualenv 22 | $ python3 -m venv ~/.virtualenvs/shaarli 23 | 24 | # activate the 'shaarli' virtualenv 25 | $ source ~/.virtualenvs/shaarli/bin/activate 26 | 27 | # install shaarli-client 28 | (shaarli) $ pip install shaarli-client 29 | 30 | # check which packages have been installed 31 | $ pip freeze 32 | PyJWT==1.4.2 33 | requests==2.13.0 34 | requests-jwt==0.4 35 | shaarli-client==0.1.0 36 | 37 | From the source code 38 | -------------------- 39 | 40 | To get ``shaarli-client`` sources and install it in a new `virtualenv`_: 41 | 42 | .. code-block:: bash 43 | 44 | # fetch the sources 45 | $ git clone https://github.com/shaarli/python-shaarli-client 46 | $ cd python-shaarli-client 47 | 48 | # create and activate a new 'shaarli' virtualenv 49 | $ python3 -m venv ~/.virtualenvs/shaarli 50 | $ source ~/.virtualenvs/shaarli/bin/activate 51 | 52 | # build and install shaarli-client 53 | (shaarli) $ python setup.py install 54 | 55 | # check which packages have been installed 56 | $ pip freeze 57 | PyJWT==1.4.2 58 | requests==2.13.0 59 | requests-jwt==0.4 60 | shaarli-client==0.1.0 61 | 62 | You can also use ``pip`` to install directly from the git repository: 63 | 64 | .. code-block:: bash 65 | 66 | $ python3 -m venv ~/.virtualenvs/shaarli 67 | $ source ~/.virtualenvs/shaarli/bin/activate 68 | (shaarli) $ pip3 install git+https://github.com/virtualtam/python-shaarli-client@master # or any other branch/tag 69 | -------------------------------------------------------------------------------- /docs/user/usage.rst: -------------------------------------------------------------------------------- 1 | Usage 2 | ===== 3 | 4 | Once installed, ``shaarli-client`` provides the ``shaarli`` command, 5 | which allows to interact with a Shaarli instance's REST API. 6 | 7 | Getting help 8 | ------------ 9 | 10 | The ``-h`` and ``--help`` flags allow to display help for any command or sub-command: 11 | 12 | .. code-block:: bash 13 | 14 | $ shaarli -h 15 | 16 | usage: shaarli [-h] [-c CONFIG] [-i INSTANCE] [-u URL] [-s SECRET] 17 | [-f {json,pprint,text}] [-o OUTFILE] [--insecure] 18 | {get-info,get-links,post-link,put-link,get-tags,get-tag,put-tag,delete-tag,delete-link} 19 | ... 20 | positional arguments: 21 | {get-info,get-links,post-link,put-link,get-tags,get-tag,put-tag,delete-tag,delete-link} 22 | REST API endpoint 23 | get-info Get information about this instance 24 | get-links Get a collection of links ordered by creation date 25 | post-link Create a new link or note 26 | put-link Update an existing link or note 27 | get-tags Get all tags 28 | get-tag Get a single tag 29 | put-tag Rename an existing tag 30 | delete-tag Delete a tag from every link where it is used 31 | delete-link Delete a link 32 | 33 | optional arguments: 34 | -h, --help show this help message and exit 35 | -c CONFIG, --config CONFIG 36 | Configuration file 37 | -i INSTANCE, --instance INSTANCE 38 | Shaarli instance (configuration alias) 39 | -u URL, --url URL Shaarli instance URL 40 | -s SECRET, --secret SECRET 41 | API secret 42 | -f {json,pprint,text}, --format {json,pprint,text} 43 | Output formatting 44 | -o OUTFILE, --outfile OUTFILE 45 | File to save the program output to 46 | --insecure Bypass API SSL/TLS certificate verification 47 | 48 | 49 | .. code-block:: bash 50 | 51 | $ shaarli get-links -h 52 | 53 | usage: shaarli get-links [-h] [--limit LIMIT] [--offset OFFSET] 54 | [--searchtags SEARCHTAGS [SEARCHTAGS ...]] 55 | [--searchterm SEARCHTERM [SEARCHTERM ...]] 56 | [--visibility {all,private,public}] 57 | 58 | optional arguments: 59 | -h, --help show this help message and exit 60 | --limit LIMIT Number of links to retrieve or 'all' 61 | --offset OFFSET Offset from which to start listing links 62 | --searchtags SEARCHTAGS [SEARCHTAGS ...] 63 | List of tags 64 | --searchterm SEARCHTERM [SEARCHTERM ...] 65 | Search terms across all links fields 66 | --visibility {all,private,public} 67 | Filter links by visibility 68 | 69 | 70 | Examples 71 | -------- 72 | 73 | General syntax 74 | ~~~~~~~~~~~~~~ 75 | 76 | .. code-block:: bash 77 | 78 | $ shaarli 79 | 80 | 81 | .. note:: The following examples assume a :doc:`configuration` file is used 82 | 83 | GET info 84 | ~~~~~~~~ 85 | 86 | .. code-block:: bash 87 | 88 | $ shaarli get-info 89 | 90 | 91 | .. code-block:: json 92 | 93 | { 94 | "global_counter": 1502, 95 | "private_counter": 5, 96 | "settings": { 97 | "default_private_links": false, 98 | "enabled_plugins": [ 99 | "markdown", 100 | "archiveorg" 101 | ], 102 | "header_link": "?", 103 | "timezone": "Europe/Paris", 104 | "title": "Yay!" 105 | } 106 | } 107 | 108 | 109 | GET links 110 | ~~~~~~~~~ 111 | 112 | .. code-block:: bash 113 | 114 | $ shaarli get-links --searchtags super hero 115 | 116 | 117 | .. code-block:: json 118 | 119 | [ 120 | { 121 | "created": "2015-02-22T15:14:41+00:00", 122 | "description": "", 123 | "id": 486, 124 | "private": false, 125 | "shorturl": null, 126 | "tags": [ 127 | "wtf", 128 | "kitsch", 129 | "super", 130 | "hero", 131 | "spider", 132 | "man", 133 | "parody" 134 | ], 135 | "title": "Italian Spiderman", 136 | "updated": "2017-03-10T19:53:34+01:00", 137 | "url": "https://vimeo.com/42254051" 138 | }, 139 | { 140 | "created": "2014-06-14T09:13:36+00:00", 141 | "description": "", 142 | "id": 970, 143 | "private": false, 144 | "shorturl": null, 145 | "tags": [ 146 | "super", 147 | "hero", 148 | "comics", 149 | "spider", 150 | "man", 151 | "costume", 152 | "vintage" 153 | ], 154 | "title": "Here's Every Costume Spider-Man Has Ever Worn", 155 | "updated": "2017-03-10T19:53:34+01:00", 156 | "url": "http://mashable.com/2014/05/01/spider-man-costume" 157 | } 158 | ] 159 | 160 | 161 | POST link 162 | ~~~~~~~~~ 163 | 164 | .. code-block:: bash 165 | 166 | $ shaarli post-link --url https://w3c.github.io/activitypub/ 167 | 168 | 169 | .. code-block:: json 170 | 171 | { 172 | "created": "2018-06-04T20:35:12+00:00", 173 | "description": "", 174 | "id": 3252, 175 | "private": false, 176 | "shorturl": "kMkHHQ", 177 | "tags": [], 178 | "title": "https://w3c.github.io/activitypub/", 179 | "updated": "", 180 | "url": "https://w3c.github.io/activitypub/" 181 | } 182 | 183 | 184 | PUT link 185 | ~~~~~~~~ 186 | 187 | .. code-block:: bash 188 | 189 | shaarli put-link --private 3252 190 | 191 | 192 | .. code-block:: json 193 | 194 | { 195 | "created": "2018-06-04T20:35:12+00:00", 196 | "description": "", 197 | "id": 3252, 198 | "private": true, 199 | "shorturl": "kMkHHQ", 200 | "tags": [], 201 | "title": "?kMkHHQ", 202 | "updated": "2018-06-04T21:57:44+00:00", 203 | "url": "http://aaron.localdomain/~virtualtam/shaarli/?kMkHHQ" 204 | } 205 | 206 | 207 | GET tags 208 | ~~~~~~~~ 209 | 210 | .. code-block:: bash 211 | 212 | $ shaarli get-tags --limit 5 213 | 214 | 215 | .. code-block:: json 216 | 217 | [ 218 | { 219 | "name": "bananas", 220 | "occurrences": 312 221 | }, 222 | { 223 | "name": "snakes", 224 | "occurrences": 247 225 | }, 226 | { 227 | "name": "ladders", 228 | "occurrences": 240 229 | }, 230 | { 231 | "name": "submarines", 232 | "occurrences": 48 233 | }, 234 | { 235 | "name": "yellow", 236 | "occurrences": 27 237 | } 238 | ] 239 | 240 | 241 | GET tag 242 | ~~~~~~~ 243 | 244 | .. code-block:: bash 245 | 246 | $ shaarli get-tag bananas 247 | 248 | 249 | .. code-block:: json 250 | 251 | { 252 | "name": "bananas", 253 | "occurrences": 312 254 | } 255 | 256 | 257 | PUT tag 258 | ~~~~~~~ 259 | 260 | .. code-block:: bash 261 | 262 | $ shaarli put-tag w4c --name w3c 263 | 264 | 265 | .. code-block:: json 266 | 267 | { 268 | "name": "w3c", 269 | "occurrences": 5 270 | } 271 | 272 | 273 | New lines/line breaks 274 | ~~~~~~~~~~~~~~~~~~~~~ 275 | 276 | If you need to include line breaks in your descriptions, use a literal newline ``\n`` and `$'...'` around the description: 277 | 278 | .. code-block:: bash 279 | 280 | $ shaarli post-link --url https://example.com/ --description $'One\nword\nper\nline'. 281 | 282 | 283 | NOT (minus) operator 284 | ~~~~~~~~~~~~~~~~~~~~~ 285 | 286 | It is required to pass all values to `--searchtags` as a quoted string: 287 | 288 | .. code-block:: bash 289 | 290 | $ shaarli get-links --searchtags "video -idontwantthistag" 291 | 292 | The value passed to --searchtags must not start with a dash, a workaround is to start the string with a space: 293 | 294 | .. code-block:: bash 295 | 296 | $ shaarli get-links --searchtags " -idontwantthistag -northisone" 297 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Add files or directories to the blacklist. They should be base names, not 11 | # paths. 12 | ignore=.git,.tox,docs 13 | 14 | # Add files or directories matching the regex patterns to the blacklist. The 15 | # regex matches against base names, not paths. 16 | ignore-patterns= 17 | 18 | # Pickle collected data for later comparisons. 19 | persistent=yes 20 | 21 | # List of plugins (as comma separated values of python modules names) to load, 22 | # usually to register additional checkers. 23 | load-plugins= 24 | 25 | # Use multiple processes to speed up Pylint. 26 | jobs=4 27 | 28 | # Allow loading of arbitrary C extensions. Extensions are imported into the 29 | # active Python interpreter and may run arbitrary code. 30 | unsafe-load-any-extension=no 31 | 32 | # A comma-separated list of package or module names from where C extensions may 33 | # be loaded. Extensions are loading into the active Python interpreter and may 34 | # run arbitrary code 35 | extension-pkg-whitelist= 36 | 37 | # Allow optimization of some AST trees. This will activate a peephole AST 38 | # optimizer, which will apply various small optimizations. For instance, it can 39 | # be used to obtain the result of joining multiple strings with the addition 40 | # operator. Joining a lot of strings can lead to a maximum recursion error in 41 | # Pylint and this flag can prevent that. It has one side effect, the resulting 42 | # AST will be different than the one from reality. This option is deprecated 43 | # and it will be removed in Pylint 2.0. 44 | optimize-ast=no 45 | 46 | 47 | [MESSAGES CONTROL] 48 | 49 | # Only show warnings with the listed confidence levels. Leave empty to show 50 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 51 | confidence= 52 | 53 | # Enable the message, report, category or checker with the given id(s). You can 54 | # either give multiple identifier separated by comma (,) or put this option 55 | # multiple time (only on the command line, not in the configuration file where 56 | # it should appear only once). See also the "--disable" option for examples. 57 | #enable= 58 | 59 | # Disable the message, report, category or checker with the given id(s). You 60 | # can either give multiple identifiers separated by comma (,) or put this 61 | # option multiple times (only on the command line, not in the configuration 62 | # file where it should appear only once).You can also use "--disable=all" to 63 | # disable everything first and then reenable specific checks. For example, if 64 | # you want to run only the similarities checker, you can use "--disable=all 65 | # --enable=similarities". If you want to run only the classes checker, but have 66 | # no Warning level messages displayed, use"--disable=all --enable=classes 67 | # --disable=W" 68 | disable=print-statement,parameter-unpacking,unpacking-in-except,old-raise-syntax,backtick,import-star-module-level,apply-builtin,basestring-builtin,buffer-builtin,cmp-builtin,coerce-builtin,execfile-builtin,file-builtin,long-builtin,raw_input-builtin,reduce-builtin,standarderror-builtin,unicode-builtin,xrange-builtin,coerce-method,delslice-method,getslice-method,setslice-method,no-absolute-import,old-division,dict-iter-method,dict-view-method,next-method-called,metaclass-assignment,indexing-exception,raising-string,reload-builtin,oct-method,hex-method,nonzero-method,cmp-method,input-builtin,round-builtin,intern-builtin,unichr-builtin,map-builtin-not-iterating,zip-builtin-not-iterating,range-builtin-not-iterating,filter-builtin-not-iterating,using-cmp-argument,long-suffix,old-ne-operator,old-octal-literal,suppressed-message,useless-suppression,super-with-arguments,simplifiable-if-expression,raise-missing-from 69 | 70 | 71 | [REPORTS] 72 | 73 | # Set the output format. Available formats are text, parseable, colorized, msvs 74 | # (visual studio) and html. You can also give a reporter class, eg 75 | # mypackage.mymodule.MyReporterClass. 76 | output-format=text 77 | 78 | # Put messages in a separate file for each module / package specified on the 79 | # command line instead of printing them on stdout. Reports (if any) will be 80 | # written in a file name "pylint_global.[txt|html]". This option is deprecated 81 | # and it will be removed in Pylint 2.0. 82 | files-output=no 83 | 84 | # Tells whether to display a full report or only the messages 85 | reports=no 86 | 87 | # Python expression which should return a note less than 10 (10 is the highest 88 | # note). You have access to the variables errors warning, statement which 89 | # respectively contain the number of errors / warnings messages and the total 90 | # number of statements analyzed. This is used by the global evaluation report 91 | # (RP0004). 92 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 93 | 94 | # Template used to display messages. This is a python new-style format string 95 | # used to format the message information. See doc for all details 96 | #msg-template= 97 | 98 | 99 | [SPELLING] 100 | 101 | # Spelling dictionary name. Available dictionaries: none. To make it working 102 | # install python-enchant package. 103 | spelling-dict= 104 | 105 | # List of comma separated words that should not be checked. 106 | spelling-ignore-words= 107 | 108 | # A path to a file that contains private dictionary; one word per line. 109 | spelling-private-dict-file= 110 | 111 | # Tells whether to store unknown words to indicated private dictionary in 112 | # --spelling-private-dict-file option instead of raising a message. 113 | spelling-store-unknown-words=no 114 | 115 | 116 | [VARIABLES] 117 | 118 | # Tells whether we should check for unused import in __init__ files. 119 | init-import=no 120 | 121 | # A regular expression matching the name of dummy variables (i.e. expectedly 122 | # not used). 123 | dummy-variables-rgx=(_+[a-zA-Z0-9]*?$)|dummy 124 | 125 | # List of additional names supposed to be defined in builtins. Remember that 126 | # you should avoid to define new builtins when possible. 127 | additional-builtins= 128 | 129 | # List of strings which can identify a callback function by name. A callback 130 | # name must start or end with one of those strings. 131 | callbacks=cb_,_cb 132 | 133 | # List of qualified module names which can have objects that can redefine 134 | # builtins. 135 | redefining-builtins-modules=six.moves,future.builtins 136 | 137 | 138 | [FORMAT] 139 | 140 | # Maximum number of characters on a single line. 141 | max-line-length=80 142 | 143 | # Regexp for a line that is allowed to be longer than the limit. 144 | ignore-long-lines=^\s*(# )??$ 145 | 146 | # Allow the body of an if to be on the same line as the test if there is no 147 | # else. 148 | single-line-if-stmt=no 149 | 150 | # List of optional constructs for which whitespace checking is disabled. `dict- 151 | # separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. 152 | # `trailing-comma` allows a space between comma and closing bracket: (a, ). 153 | # `empty-line` allows space-only lines. 154 | no-space-check=trailing-comma,dict-separator 155 | 156 | # Maximum number of lines in a module 157 | max-module-lines=1000 158 | 159 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 160 | # tab). 161 | indent-string=' ' 162 | 163 | # Number of spaces of indent required inside a hanging or continued line. 164 | indent-after-paren=4 165 | 166 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 167 | expected-line-ending-format= 168 | 169 | 170 | [TYPECHECK] 171 | 172 | # Tells whether missing members accessed in mixin class should be ignored. A 173 | # mixin class is detected if its name ends with "mixin" (case insensitive). 174 | ignore-mixin-members=yes 175 | 176 | # List of module names for which member attributes should not be checked 177 | # (useful for modules/projects where namespaces are manipulated during runtime 178 | # and thus existing member attributes cannot be deduced by static analysis. It 179 | # supports qualified module names, as well as Unix pattern matching. 180 | ignored-modules= 181 | 182 | # List of class names for which member attributes should not be checked (useful 183 | # for classes with dynamically set attributes). This supports the use of 184 | # qualified names. 185 | ignored-classes=optparse.Values,thread._local,_thread._local 186 | 187 | # List of members which are set dynamically and missed by pylint inference 188 | # system, and so shouldn't trigger E1101 when accessed. Python regular 189 | # expressions are accepted. 190 | generated-members= 191 | 192 | # List of decorators that produce context managers, such as 193 | # contextlib.contextmanager. Add to this list to register other decorators that 194 | # produce valid context managers. 195 | contextmanager-decorators=contextlib.contextmanager 196 | 197 | 198 | [MISCELLANEOUS] 199 | 200 | # List of note tags to take in consideration, separated by a comma. 201 | notes=FIXME,XXX,TODO 202 | 203 | 204 | [BASIC] 205 | 206 | # Good variable names which should always be accepted, separated by a comma 207 | good-names=i,j,k,ex,Run,_ 208 | 209 | # Bad variable names which should always be refused, separated by a comma 210 | bad-names=foo,bar,baz,toto,tutu,tata 211 | 212 | # Colon-delimited sets of names that determine each other's naming style when 213 | # the name regexes allow several styles. 214 | name-group= 215 | 216 | # Include a hint for the correct naming format with invalid-name 217 | include-naming-hint=no 218 | 219 | # List of decorators that produce properties, such as abc.abstractproperty. Add 220 | # to this list to register other decorators that produce valid properties. 221 | property-classes=abc.abstractproperty 222 | 223 | # Regular expression matching correct module names 224 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 225 | 226 | # Naming hint for module names 227 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 228 | 229 | # Regular expression matching correct constant names 230 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 231 | 232 | # Naming hint for constant names 233 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 234 | 235 | # Regular expression matching correct class names 236 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 237 | 238 | # Naming hint for class names 239 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 240 | 241 | # Regular expression matching correct function names 242 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 243 | 244 | # Naming hint for function names 245 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 246 | 247 | # Regular expression matching correct method names 248 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 249 | 250 | # Naming hint for method names 251 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 252 | 253 | # Regular expression matching correct attribute names 254 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 255 | 256 | # Naming hint for attribute names 257 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 258 | 259 | # Regular expression matching correct argument names 260 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 261 | 262 | # Naming hint for argument names 263 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 264 | 265 | # Regular expression matching correct variable names 266 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 267 | 268 | # Naming hint for variable names 269 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 270 | 271 | # Regular expression matching correct class attribute names 272 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 273 | 274 | # Naming hint for class attribute names 275 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 276 | 277 | # Regular expression matching correct inline iteration names 278 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 279 | 280 | # Naming hint for inline iteration names 281 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 282 | 283 | # Regular expression which should only match function or class names that do 284 | # not require a docstring. 285 | no-docstring-rgx=^_ 286 | 287 | # Minimum line length for functions/classes that require docstrings, shorter 288 | # ones are exempt. 289 | docstring-min-length=-1 290 | 291 | 292 | [ELIF] 293 | 294 | # Maximum number of nested blocks for function / method body 295 | max-nested-blocks=5 296 | 297 | 298 | [LOGGING] 299 | 300 | # Logging modules to check that the string format arguments are in logging 301 | # function parameter format 302 | logging-modules=logging 303 | 304 | 305 | [SIMILARITIES] 306 | 307 | # Minimum lines number of a similarity. 308 | min-similarity-lines=4 309 | 310 | # Ignore comments when computing similarities. 311 | ignore-comments=yes 312 | 313 | # Ignore docstrings when computing similarities. 314 | ignore-docstrings=yes 315 | 316 | # Ignore imports when computing similarities. 317 | ignore-imports=no 318 | 319 | 320 | [DESIGN] 321 | 322 | # Maximum number of arguments for function / method 323 | max-args=5 324 | 325 | # Argument names that match this expression will be ignored. Default to name 326 | # with leading underscore 327 | ignored-argument-names=_.* 328 | 329 | # Maximum number of locals for function / method body 330 | max-locals=15 331 | 332 | # Maximum number of return / yield for function / method body 333 | max-returns=6 334 | 335 | # Maximum number of branch for function / method body 336 | max-branches=12 337 | 338 | # Maximum number of statements in function / method body 339 | max-statements=50 340 | 341 | # Maximum number of parents for a class (see R0901). 342 | max-parents=7 343 | 344 | # Maximum number of attributes for a class (see R0902). 345 | max-attributes=7 346 | 347 | # Minimum number of public methods for a class (see R0903). 348 | min-public-methods=2 349 | 350 | # Maximum number of public methods for a class (see R0904). 351 | max-public-methods=20 352 | 353 | # Maximum number of boolean expressions in a if statement 354 | max-bool-expr=5 355 | 356 | 357 | [IMPORTS] 358 | 359 | # Deprecated modules which should not be used, separated by a comma 360 | deprecated-modules=optparse 361 | 362 | # Create a graph of every (i.e. internal and external) dependencies in the 363 | # given file (report RP0402 must not be disabled) 364 | import-graph= 365 | 366 | # Create a graph of external dependencies in the given file (report RP0402 must 367 | # not be disabled) 368 | ext-import-graph= 369 | 370 | # Create a graph of internal dependencies in the given file (report RP0402 must 371 | # not be disabled) 372 | int-import-graph= 373 | 374 | # Force import order to recognize a module as part of the standard 375 | # compatibility libraries. 376 | known-standard-library= 377 | 378 | # Force import order to recognize a module as part of a third party library. 379 | known-third-party=enchant 380 | 381 | # Analyse import fallback blocks. This can be used to support both Python 2 and 382 | # 3 compatible code, which means that the block might have code that exists 383 | # only in one or another interpreter, leading to false positives when analysed. 384 | analyse-fallback-blocks=no 385 | 386 | 387 | [CLASSES] 388 | 389 | # List of method names used to declare (i.e. assign) instance attributes. 390 | defining-attr-methods=__init__,__new__,setUp 391 | 392 | # List of valid names for the first argument in a class method. 393 | valid-classmethod-first-arg=cls 394 | 395 | # List of valid names for the first argument in a metaclass class method. 396 | valid-metaclass-classmethod-first-arg=mcs 397 | 398 | # List of member names, which should be excluded from the protected access 399 | # warning. 400 | exclude-protected=_asdict,_fields,_replace,_source,_make 401 | 402 | 403 | [EXCEPTIONS] 404 | 405 | # Exceptions that will emit a warning when being caught. Defaults to 406 | # "Exception" 407 | overgeneral-exceptions=Exception 408 | -------------------------------------------------------------------------------- /requirements/ci.txt: -------------------------------------------------------------------------------- 1 | tox==3.0.0 2 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | -r ci.txt 2 | -r docs.txt 3 | -r tests.txt 4 | twine==1.11.0 5 | -------------------------------------------------------------------------------- /requirements/docs.txt: -------------------------------------------------------------------------------- 1 | docutils==0.14 2 | Sphinx>=1.7 3 | sphinx_rtd_theme>=0.3 4 | -------------------------------------------------------------------------------- /requirements/tests.txt: -------------------------------------------------------------------------------- 1 | coverage==5.5 2 | isort==5.8.0 3 | pycodestyle==2.7.0 4 | pydocstyle==6.0.0 5 | pylint==2.7.4 6 | pytest==6.2.3 7 | pytest-cov==2.11.1 8 | pytest-pylint==0.18.0 9 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [isort] 2 | line_length=80 3 | skip=.git,.tox,docs 4 | 5 | [pycodestyle] 6 | count=True 7 | exclude=.git,.tox,build,docs,__pycache__ 8 | max-line-length=80 9 | statistics=True 10 | 11 | [pydocstyle] 12 | ignore=D105,D203,D213,D400,D401,D415 13 | match-dir=(?!build|docs)[^\.].* 14 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Setup script for shaarli-client""" 3 | import codecs 4 | import os 5 | import re 6 | 7 | from setuptools import find_packages, setup 8 | 9 | 10 | def get_long_description(): 11 | """Reads the main README.rst to get the program's long description""" 12 | with codecs.open('README.rst', 'r', 'utf-8') as f_readme: 13 | return f_readme.read() 14 | 15 | 16 | def get_package_metadata(attribute): 17 | """Reads metadata from the main package's __init__""" 18 | with open(os.path.join('shaarli_client', '__init__.py'), 'r') as f_init: 19 | return re.search( 20 | r'^__{attr}__\s*=\s*[\'"]([^\'"]*)[\'"]'.format(attr=attribute), 21 | f_init.read(), re.MULTILINE 22 | ).group(1) 23 | 24 | 25 | setup( 26 | name=get_package_metadata('title'), 27 | version=get_package_metadata('version'), 28 | description=get_package_metadata('brief'), 29 | long_description=get_long_description(), 30 | author=get_package_metadata('author'), 31 | maintainer='VirtualTam', 32 | maintainer_email='virtualtam@flibidi.net', 33 | license='MIT', 34 | url='https://github.com/shaarli/python-shaarli-client', 35 | keywords='bookmark bookmarking shaarli social', 36 | packages=find_packages(exclude=['tests.*', 'tests']), 37 | entry_points={ 38 | 'console_scripts': [ 39 | 'shaarli = shaarli_client.main:main', 40 | ], 41 | }, 42 | install_requires=[ 43 | 'requests >= 2.25', 44 | 'pyjwt == 2.4.0' 45 | ], 46 | classifiers=[ 47 | 'Development Status :: 3 - Alpha', 48 | 'Environment :: Console', 49 | 'Intended Audience :: Developers', 50 | 'Intended Audience :: End Users/Desktop', 51 | 'License :: OSI Approved :: MIT License', 52 | 'Natural Language :: English', 53 | 'Operating System :: OS Independent', 54 | 'Programming Language :: Python', 55 | 'Programming Language :: Python :: 3', 56 | 'Programming Language :: Python :: 3.6', 57 | 'Programming Language :: Python :: 3.7', 58 | 'Programming Language :: Python :: 3.8', 59 | 'Programming Language :: Python :: 3.9', 60 | 'Topic :: Utilities', 61 | ] 62 | ) 63 | -------------------------------------------------------------------------------- /shaarli_client/__init__.py: -------------------------------------------------------------------------------- 1 | """shaarli-client""" 2 | __title__ = 'shaarli_client' 3 | __brief__ = 'CLI to interact with a Shaarli instance' 4 | __version__ = '0.5.0' 5 | __author__ = 'The Shaarli Community' 6 | -------------------------------------------------------------------------------- /shaarli_client/client/__init__.py: -------------------------------------------------------------------------------- 1 | """Shaarli REST API clients""" 2 | from .v1 import InvalidEndpointParameters, ShaarliV1Client 3 | -------------------------------------------------------------------------------- /shaarli_client/client/v1.py: -------------------------------------------------------------------------------- 1 | """Shaarli REST API v1 client""" 2 | import calendar 3 | import time 4 | from argparse import Action, ArgumentTypeError 5 | 6 | import jwt 7 | import requests 8 | 9 | 10 | def check_positive_integer(value): 11 | """Ensure a value is a positive integer""" 12 | try: 13 | intval = int(value) 14 | except ValueError: 15 | raise ArgumentTypeError("%s is not a positive integer" % value) 16 | 17 | if intval < 0: 18 | raise ArgumentTypeError("%s is not a positive integer" % value) 19 | 20 | return intval 21 | 22 | 23 | class TextFormatAction(Action): 24 | """Format text fields""" 25 | 26 | # pylint: disable=too-few-public-methods 27 | 28 | def __call__(self, parser, namespace, values, option_string=None): 29 | """Convert a list of strings to a text string 30 | 31 | Source: ["term1", "term2", "term3", ...] 32 | Formatted string: "term1 term2 term3 ..." 33 | 34 | Actual query: term1+term2+term3+... 35 | """ 36 | setattr(namespace, self.dest, ' '.join(values)) 37 | 38 | 39 | class InvalidEndpointParameters(Exception): 40 | """Raised when unauthorized endpoint parameters are used""" 41 | 42 | def __init__(self, endpoint_name, parameters): 43 | """Custom exception message""" 44 | super(InvalidEndpointParameters, self).__init__( 45 | "Invalid parameters for endpoint '%s': %s" % ( 46 | endpoint_name, 47 | ", ".join(parameters) 48 | ) 49 | ) 50 | 51 | 52 | class ShaarliV1Client: 53 | """Shaarli REST API v1 client""" 54 | 55 | endpoints = { 56 | 'get-info': { 57 | 'path': 'info', 58 | 'method': 'GET', 59 | 'help': "Get information about this instance", 60 | 'params': None, 61 | }, 62 | 'get-links': { 63 | 'path': 'links', 64 | 'method': 'GET', 65 | 'help': "Get a collection of links ordered by creation date", 66 | 'params': { 67 | 'offset': { 68 | 'help': "Offset from which to start listing links", 69 | 'type': int, 70 | }, 71 | 'limit': { 72 | 'help': "Number of links to retrieve or 'all'", 73 | }, 74 | 'searchtags': { 75 | 'help': "List of tags", 76 | 'nargs': '+', 77 | 'action': TextFormatAction, 78 | }, 79 | 'searchterm': { 80 | 'help': "Search terms across all links fields", 81 | 'nargs': '+', 82 | 'action': TextFormatAction, 83 | }, 84 | 'visibility': { 85 | 'choices': ['all', 'private', 'public'], 86 | 'help': "Filter links by visibility", 87 | }, 88 | }, 89 | }, 90 | 'post-link': { 91 | 'path': 'links', 92 | 'method': 'POST', 93 | 'help': "Create a new link or note", 94 | 'params': { 95 | 'description': { 96 | 'action': TextFormatAction, 97 | 'help': "Link description", 98 | 'nargs': '+', 99 | }, 100 | 'private': { 101 | 'action': 'store_true', 102 | 'help': "Link visibility", 103 | }, 104 | 'tags': { 105 | 'help': "List of tags associated with the link", 106 | 'nargs': '+', 107 | }, 108 | 'title': { 109 | 'action': TextFormatAction, 110 | 'help': "Link title", 111 | 'nargs': '+', 112 | }, 113 | 'url': { 114 | 'help': "Link URL", 115 | }, 116 | }, 117 | }, 118 | 'put-link': { 119 | 'path': 'links', 120 | 'method': 'PUT', 121 | 'help': "Update an existing link or note", 122 | 'resource': { 123 | 'help': "Link ID", 124 | 'type': check_positive_integer, 125 | }, 126 | 'params': { 127 | 'description': { 128 | 'action': TextFormatAction, 129 | 'help': "Link description", 130 | 'nargs': '+', 131 | }, 132 | 'private': { 133 | 'action': 'store_true', 134 | 'help': "Link visibility", 135 | }, 136 | 'tags': { 137 | 'help': "List of tags associated with the link", 138 | 'nargs': '+', 139 | }, 140 | 'title': { 141 | 'action': TextFormatAction, 142 | 'help': "Link title", 143 | 'nargs': '+', 144 | }, 145 | 'url': { 146 | 'help': "Link URL", 147 | }, 148 | }, 149 | }, 150 | 'get-tags': { 151 | 'path': 'tags', 152 | 'method': 'GET', 153 | 'help': "Get all tags", 154 | 'params': { 155 | 'offset': { 156 | 'help': "Offset from which to start listing tags", 157 | 'type': int, 158 | }, 159 | 'limit': { 160 | 'help': "Number of tags to retrieve or 'all'", 161 | }, 162 | 'visibility': { 163 | 'choices': ['all', 'private', 'public'], 164 | }, 165 | }, 166 | }, 167 | 'get-tag': { 168 | 'path': 'tags', 169 | 'method': 'GET', 170 | 'help': "Get a single tag", 171 | 'resource': { 172 | 'help': "Tag name (case-sensitive))", 173 | }, 174 | }, 175 | 'put-tag': { 176 | 'path': 'tags', 177 | 'method': 'PUT', 178 | 'help': "Rename an existing tag", 179 | 'resource': { 180 | 'help': "Tag name (case-sensitive)", 181 | }, 182 | 'params': { 183 | 'name': { 184 | 'help': "New tag name", 185 | }, 186 | }, 187 | }, 188 | 'delete-tag': { 189 | 'path': 'tags', 190 | 'method': 'DELETE', 191 | 'help': "Delete a tag from every link where it is used", 192 | 'resource': { 193 | 'help': "Tag name (case-sensitive)", 194 | }, 195 | }, 196 | 'delete-link': { 197 | 'path': 'links', 198 | 'method': 'DELETE', 199 | 'help': "Delete a link", 200 | 'resource': { 201 | 'help': "Link ID", 202 | 'type': check_positive_integer, 203 | }, 204 | }, 205 | } 206 | 207 | def __init__(self, uri, secret): 208 | """Client constructor""" 209 | if not uri: 210 | raise TypeError("Missing Shaarli URI") 211 | if not secret: 212 | raise TypeError("Missing Shaarli secret") 213 | 214 | self.uri = uri.rstrip('/') 215 | self.secret = secret 216 | self.version = 1 217 | 218 | @classmethod 219 | def _check_endpoint_params(cls, endpoint_name, params): 220 | """Check parameters are allowed for a given endpoint""" 221 | if not params: 222 | return 223 | 224 | invalid_parameters = list() 225 | 226 | for param in params.keys(): 227 | if param not in cls.endpoints[endpoint_name]['params'].keys(): 228 | invalid_parameters.append(param) 229 | 230 | if invalid_parameters: 231 | raise InvalidEndpointParameters(endpoint_name, invalid_parameters) 232 | 233 | @classmethod 234 | def _retrieve_http_params(cls, args): 235 | """Retrieve REST HTTP parameters from an Argparse Namespace 236 | 237 | This is done by introspecing the parsed arguments with reference to 238 | the client's endpoint metadata. 239 | """ 240 | endpoint = cls.endpoints[args.endpoint_name] 241 | 242 | if not endpoint.get('resource'): 243 | path = endpoint['path'] 244 | else: 245 | path = '%s/%s' % (endpoint['path'], args.resource) 246 | 247 | if not endpoint.get('params'): 248 | return (endpoint['method'], path, {}) 249 | 250 | params = { 251 | param: getattr(args, param) 252 | for param in endpoint.get('params').keys() 253 | if hasattr(args, param) 254 | } 255 | 256 | return (endpoint['method'], path, params) 257 | 258 | def _request(self, method, endpoint, params, verify_certs=True): 259 | """Send an HTTP request to this instance""" 260 | encoded_token = jwt.encode( 261 | {'iat': calendar.timegm(time.gmtime())}, 262 | self.secret, 263 | algorithm='HS512', 264 | ) 265 | headers = {'Authorization': 'Bearer %s' % encoded_token} 266 | 267 | endpoint_uri = '%s/api/v%d/%s' % (self.uri, self.version, endpoint) 268 | 269 | if method == 'GET': 270 | return requests.request( 271 | method, 272 | endpoint_uri, 273 | headers=headers, 274 | params=params, 275 | verify=verify_certs 276 | ) 277 | return requests.request( 278 | method, 279 | endpoint_uri, 280 | headers=headers, 281 | json=params, 282 | verify=verify_certs 283 | ) 284 | 285 | def request(self, args): 286 | """Send a parameterized request to this instance""" 287 | verify_certs = False if args.insecure else True 288 | return self._request(* self._retrieve_http_params(args), 289 | verify_certs) 290 | 291 | def get_info(self): 292 | """Get information about this instance""" 293 | return self._request('GET', 'info', {}) 294 | 295 | def get_links(self, params): 296 | """Get a collection of links ordered by creation date""" 297 | self._check_endpoint_params('get-links', params) 298 | return self._request('GET', 'links', params) 299 | 300 | def post_link(self, params): 301 | """Create a new link or note""" 302 | self._check_endpoint_params('post-link', params) 303 | return self._request('POST', 'links', params) 304 | 305 | def put_link(self, resource, params): 306 | """Update an existing link or note""" 307 | self._check_endpoint_params('put-link', params) 308 | return self._request('PUT', 'links/%d' % resource, params) 309 | 310 | def get_tags(self, params): 311 | """Get a list of all tags""" 312 | self._check_endpoint_params('get-tags', params) 313 | return self._request('GET', 'tags', params) 314 | 315 | def get_tag(self, resource, params): 316 | """Get a single tag""" 317 | self._check_endpoint_params('get-tag', params) 318 | return self._request('GET', 'tags/%s' % resource, params) 319 | 320 | def put_tag(self, resource, params): 321 | """Rename an existing tag""" 322 | self._check_endpoint_params('put-tag', params) 323 | return self._request('PUT', 'tags/%s' % resource, params) 324 | 325 | def delete_tag(self, resource, params): 326 | """Delete a tag""" 327 | self._check_endpoint_params('delete-tag', params) 328 | return self._request('DELETE', 'tags/%s' % resource, params) 329 | 330 | def delete_link(self, resource, params): 331 | """Delete a link""" 332 | self._check_endpoint_params('delete-link', params) 333 | return self._request('DELETE', 'links/%d' % resource, params) 334 | -------------------------------------------------------------------------------- /shaarli_client/config.py: -------------------------------------------------------------------------------- 1 | """Configuration file utilities""" 2 | import logging 3 | import os 4 | from configparser import ConfigParser 5 | from pathlib import Path 6 | 7 | 8 | class InvalidConfiguration(Exception): 9 | """Raised when invalid/no configuration is found""" 10 | 11 | def __init__(self, message): 12 | """Custom exception message""" 13 | super(InvalidConfiguration, self).__init__( 14 | "Invalid configuration: %s" % message 15 | ) 16 | 17 | 18 | def get_credentials(args): 19 | """Retrieve Shaarli authentication information""" 20 | if args.url and args.secret: 21 | # credentials passed as CLI arguments 22 | logging.warning("Passing credentials as arguments is unsafe" 23 | " and should be used for debugging only") 24 | return (args.url, args.secret) 25 | 26 | config = ConfigParser() 27 | 28 | if args.instance: 29 | instance = 'shaarli:{}'.format(args.instance) 30 | else: 31 | instance = 'shaarli' 32 | 33 | if args.config: 34 | # user-specified configuration file 35 | config_files = config.read([args.config]) 36 | else: 37 | # load configuration from a list of possible locations 38 | home = Path(os.path.expanduser('~')) 39 | config_files = config.read([ 40 | str(home / '.config' / 'shaarli' / 'client.ini'), 41 | str(home / '.shaarli_client.ini'), 42 | 'shaarli_client.ini' 43 | ]) 44 | 45 | if not config_files: 46 | raise InvalidConfiguration("No configuration file found") 47 | 48 | logging.info("Reading configuration from: %s", 49 | ', '.join([str(cfg) for cfg in config_files])) 50 | 51 | try: 52 | return (config[instance]['url'], config[instance]['secret']) 53 | except KeyError as exc: 54 | raise InvalidConfiguration("Missing entry: %s" % exc) 55 | -------------------------------------------------------------------------------- /shaarli_client/main.py: -------------------------------------------------------------------------------- 1 | """shaarli-client main CLI entrypoint""" 2 | import logging 3 | import sys 4 | from argparse import ArgumentParser 5 | 6 | from .client import ShaarliV1Client 7 | from .config import InvalidConfiguration, get_credentials 8 | from .utils import format_response, generate_all_endpoints_parsers, write_output 9 | 10 | 11 | def main(): 12 | """Main CLI entrypoint""" 13 | parser = ArgumentParser() 14 | parser.add_argument( 15 | '-c', 16 | '--config', 17 | help="Configuration file" 18 | ) 19 | parser.add_argument( 20 | '-i', 21 | '--instance', 22 | help="Shaarli instance (configuration alias)" 23 | ) 24 | parser.add_argument( 25 | '-u', 26 | '--url', 27 | help="Shaarli instance URL" 28 | ) 29 | parser.add_argument( 30 | '-s', 31 | '--secret', 32 | help="API secret" 33 | ) 34 | parser.add_argument( 35 | '-f', 36 | '--format', 37 | choices=['json', 'pprint', 'text'], 38 | default='pprint', 39 | help="Output formatting" 40 | ) 41 | parser.add_argument( 42 | '-o', 43 | '--outfile', 44 | help="File to save the program output to" 45 | ) 46 | parser.add_argument( 47 | '--insecure', 48 | action='store_true', 49 | help="Bypass API SSL/TLS certificate verification" 50 | ) 51 | 52 | subparsers = parser.add_subparsers( 53 | dest='endpoint_name', 54 | help="REST API endpoint" 55 | ) 56 | 57 | generate_all_endpoints_parsers(subparsers, ShaarliV1Client.endpoints) 58 | 59 | args = parser.parse_args() 60 | 61 | try: 62 | url, secret = get_credentials(args) 63 | response = ShaarliV1Client(url, secret).request(args) 64 | except InvalidConfiguration as exc: 65 | logging.error(exc) 66 | parser.print_help() 67 | sys.exit(1) 68 | except (KeyError, TypeError, ValueError) as exc: 69 | logging.error(exc) 70 | parser.print_help() 71 | sys.exit(1) 72 | 73 | output = format_response(args.format, response) 74 | if not args.outfile: 75 | print(output) 76 | else: 77 | write_output(args.outfile, output) 78 | 79 | 80 | if __name__ == "__main__": 81 | sys.exit(main()) 82 | -------------------------------------------------------------------------------- /shaarli_client/utils.py: -------------------------------------------------------------------------------- 1 | """Utilities""" 2 | import json 3 | 4 | 5 | def generate_endpoint_parser(subparsers, ep_name, ep_metadata): 6 | """Generate a subparser and arguments from an endpoint dictionary""" 7 | ep_parser = subparsers.add_parser(ep_name, help=ep_metadata['help']) 8 | 9 | if ep_metadata.get('resource'): 10 | ep_parser.add_argument('resource', **ep_metadata.get('resource')) 11 | 12 | if not ep_metadata.get('params'): 13 | return ep_parser 14 | 15 | for param, attributes in sorted(ep_metadata['params'].items()): 16 | ep_parser.add_argument('--%s' % param, **attributes) 17 | 18 | return ep_parser 19 | 20 | 21 | def generate_all_endpoints_parsers(subparsers, endpoints): 22 | """Generate all endpoints' subparsers from an endpoints dict""" 23 | for ep_name, ep_metadata in endpoints.items(): 24 | generate_endpoint_parser(subparsers, ep_name, ep_metadata) 25 | 26 | 27 | def format_response(output_format, response): 28 | """Format the API response to the desired output format""" 29 | if not response.content: 30 | formatted = '' 31 | elif output_format == 'json': 32 | formatted = json.dumps(response.json()) 33 | elif output_format == 'pprint': 34 | formatted = json.dumps(response.json(), sort_keys=True, indent=4) 35 | elif output_format == 'text': 36 | formatted = response.text 37 | else: 38 | raise ValueError("%s is not a supported format." % output_format) 39 | 40 | return formatted 41 | 42 | 43 | def write_output(filename, output): 44 | """Write the program output to a file""" 45 | try: 46 | with open(filename, 'w') as outfile_handler: 47 | outfile_handler.write(output) 48 | except OSError: 49 | raise OSError("Unable to write output file %s" % filename) 50 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test package""" 2 | -------------------------------------------------------------------------------- /tests/client/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for Shaarli REST API clients""" 2 | -------------------------------------------------------------------------------- /tests/client/test_v1.py: -------------------------------------------------------------------------------- 1 | """Tests for Shaarli REST API v1 client""" 2 | # pylint: disable=invalid-name,protected-access 3 | from argparse import ArgumentTypeError, Namespace 4 | from unittest import mock 5 | 6 | import pytest 7 | from requests.exceptions import InvalidSchema, InvalidURL, MissingSchema 8 | 9 | from shaarli_client.client.v1 import (InvalidEndpointParameters, 10 | ShaarliV1Client, check_positive_integer) 11 | 12 | SHAARLI_URL = 'http://domain.tld/shaarli' 13 | SHAARLI_SECRET = 's3kr37!' 14 | 15 | 16 | def test_check_positive_integer(): 17 | """A posivite integer is a positive integer""" 18 | assert check_positive_integer('0') == 0 19 | assert check_positive_integer('2378') == 2378 20 | 21 | 22 | def test_check_positive_integer_negative(): 23 | """A negative integer is not a positive integer""" 24 | with pytest.raises(ArgumentTypeError) as exc: 25 | check_positive_integer('-123') 26 | assert 'not a positive integer' in str(exc.value) 27 | 28 | 29 | def test_check_positive_integer_alpha(): 30 | """An alphanumeric string is not a positive integer""" 31 | with pytest.raises(ArgumentTypeError) as exc: 32 | check_positive_integer('abc123') 33 | assert 'not a positive integer' in str(exc.value) 34 | 35 | 36 | def test_invalid_endpoint_parameters_exception(): 37 | """Custom exception formatting""" 38 | exc = InvalidEndpointParameters('post-dummy', ['param1', 'param2']) 39 | assert str(exc) == \ 40 | "Invalid parameters for endpoint 'post-dummy': param1, param2" 41 | 42 | 43 | def test_constructor(): 44 | """Instantiate a new client""" 45 | ShaarliV1Client(SHAARLI_URL, SHAARLI_SECRET) 46 | 47 | 48 | def test_constructor_no_uri(): 49 | """Missing URI""" 50 | with pytest.raises(TypeError) as exc: 51 | ShaarliV1Client(None, SHAARLI_SECRET) 52 | assert "Missing Shaarli URI" in str(exc.value) 53 | 54 | 55 | def test_constructor_no_secret(): 56 | """Missing authentication secret""" 57 | with pytest.raises(TypeError) as exc: 58 | ShaarliV1Client(SHAARLI_URL, None) 59 | assert "Missing Shaarli secret" in str(exc.value) 60 | 61 | 62 | @pytest.mark.parametrize("test_uri", [ 63 | SHAARLI_URL, 64 | '%s/' % SHAARLI_URL, 65 | '%s///' % SHAARLI_URL, 66 | ]) 67 | def test_constructor_strip_uri(test_uri): 68 | """Ensure trailing / are stripped""" 69 | client = ShaarliV1Client(test_uri, SHAARLI_SECRET) 70 | assert client.uri == SHAARLI_URL 71 | 72 | 73 | def test_check_endpoint_params_none(): 74 | """Check parameters - none passed""" 75 | ShaarliV1Client._check_endpoint_params('get-info', None) 76 | 77 | 78 | def test_check_endpoint_params_empty(): 79 | """Check parameters - empty dict passed""" 80 | ShaarliV1Client._check_endpoint_params('get-info', {}) 81 | 82 | 83 | def test_check_endpoint_params_ok(): 84 | """Check parameters - valid params passed""" 85 | ShaarliV1Client._check_endpoint_params( 86 | 'get-links', 87 | {'offset': 3, 'limit': 100} 88 | ) 89 | 90 | 91 | def test_check_endpoint_params_nok(): 92 | """Check parameters - invalid params passed""" 93 | with pytest.raises(InvalidEndpointParameters) as exc: 94 | ShaarliV1Client._check_endpoint_params( 95 | 'get-links', 96 | {'get': 27, 'forget': 31} 97 | ) 98 | assert "'get-links':" in str(exc.value) 99 | assert 'get' in str(exc.value) 100 | assert 'forget' in str(exc.value) 101 | 102 | 103 | def test_check_endpoint_params_nok_mixed(): 104 | """Check parameters - valid & invalid params passed""" 105 | with pytest.raises(InvalidEndpointParameters) as exc: 106 | ShaarliV1Client._check_endpoint_params( 107 | 'get-links', 108 | {'offset': 200, 'preset': 27, 'headset': 31} 109 | ) 110 | assert "'get-links':" in str(exc.value) 111 | assert 'headset' in str(exc.value) 112 | assert 'preset' in str(exc.value) 113 | 114 | 115 | @mock.patch('requests.request') 116 | def test_get_info_uri(request): 117 | """Ensure the proper endpoint URI is accessed""" 118 | ShaarliV1Client(SHAARLI_URL, SHAARLI_SECRET).get_info() 119 | request.assert_called_once_with( 120 | 'GET', 121 | '%s/api/v1/info' % SHAARLI_URL, 122 | headers=mock.ANY, 123 | verify=True, 124 | params={} 125 | ) 126 | 127 | 128 | @pytest.mark.parametrize('uri, klass, msg', [ 129 | ('shaarli', MissingSchema, "No scheme supplied"), 130 | ('http:/shaarli', InvalidURL, "No host supplied"), 131 | ('htp://shaarli', InvalidSchema, "No connection adapters"), 132 | ]) 133 | def test_get_info_invalid_uri(uri, klass, msg): 134 | """Invalid URI format""" 135 | with pytest.raises(ValueError) as exc: 136 | ShaarliV1Client(uri, SHAARLI_SECRET).get_info() 137 | assert isinstance(exc.value, klass) 138 | assert msg in str(exc.value) 139 | 140 | 141 | @mock.patch('requests.request') 142 | def test_get_links_uri(request): 143 | """Ensure the proper endpoint URI is accessed""" 144 | ShaarliV1Client(SHAARLI_URL, SHAARLI_SECRET).get_links({}) 145 | request.assert_called_once_with( 146 | 'GET', 147 | '%s/api/v1/links' % SHAARLI_URL, 148 | headers=mock.ANY, 149 | verify=True, 150 | params={} 151 | ) 152 | 153 | 154 | def test_retrieve_http_params_get_info(): 155 | """Retrieve REST parameters from an Argparse Namespace - GET /info""" 156 | args = Namespace(endpoint_name='get-info') 157 | assert ShaarliV1Client._retrieve_http_params(args) == \ 158 | ('GET', 'info', {}) 159 | 160 | 161 | def test_retrieve_http_params_get_links(): 162 | """Retrieve REST parameters from an Argparse Namespace - GET /links""" 163 | args = Namespace( 164 | endpoint_name='get-links', 165 | offset=42, 166 | limit='all', 167 | visibility='public' 168 | ) 169 | assert ShaarliV1Client._retrieve_http_params(args) == \ 170 | ('GET', 'links', {'offset': 42, 'limit': 'all', 'visibility': 'public'}) 171 | 172 | 173 | def test_retrieve_http_params_get_links_searchterm(): 174 | """Retrieve REST parameters from an Argparse Namespace - GET /links""" 175 | args = Namespace( 176 | endpoint_name='get-links', 177 | searchterm='gimme+some+results' 178 | ) 179 | assert ShaarliV1Client._retrieve_http_params(args) == \ 180 | ('GET', 'links', {'searchterm': 'gimme+some+results'}) 181 | 182 | 183 | @mock.patch('requests.request') 184 | def test_post_links_uri(request): 185 | """Ensure the proper endpoint URI is accessed""" 186 | ShaarliV1Client(SHAARLI_URL, SHAARLI_SECRET).post_link({}) 187 | request.assert_called_once_with( 188 | 'POST', 189 | '%s/api/v1/links' % SHAARLI_URL, 190 | headers=mock.ANY, 191 | verify=True, 192 | json={} 193 | ) 194 | 195 | 196 | def test_retrieve_http_params_post_link(): 197 | """Retrieve REST parameters from an Argparse Namespace - POST /links""" 198 | args = Namespace( 199 | endpoint_name='post-link', 200 | description="I am not a bookmark about a link.", 201 | private=False, 202 | tags=["nope", "4891"], 203 | title="Ain't Talkin' 'bout Links", 204 | url='https://aint.talkin.bout.lin.ks' 205 | ) 206 | assert ShaarliV1Client._retrieve_http_params(args) == \ 207 | ( 208 | 'POST', 209 | 'links', 210 | { 211 | 'description': "I am not a bookmark about a link.", 212 | 'private': False, 213 | 'tags': ["nope", "4891"], 214 | 'title': "Ain't Talkin' 'bout Links", 215 | 'url': 'https://aint.talkin.bout.lin.ks' 216 | } 217 | ) 218 | 219 | 220 | def test_retrieve_http_params_post_empty_link(): 221 | """Retrieve REST parameters from an Argparse Namespace - POST /links""" 222 | args = Namespace(endpoint_name='post-link') 223 | assert ShaarliV1Client._retrieve_http_params(args) == ('POST', 'links', {}) 224 | 225 | 226 | @mock.patch('requests.request') 227 | def test_put_links_uri(request): 228 | """Ensure the proper endpoint URI is accessed""" 229 | ShaarliV1Client(SHAARLI_URL, SHAARLI_SECRET).put_link(12, {}) 230 | request.assert_called_once_with( 231 | 'PUT', 232 | '%s/api/v1/links/12' % SHAARLI_URL, 233 | headers=mock.ANY, 234 | verify=True, 235 | json={} 236 | ) 237 | 238 | 239 | def test_retrieve_http_params_put_link(): 240 | """Retrieve REST parameters from an Argparse Namespace - PUT /links""" 241 | args = Namespace( 242 | resource=46, 243 | endpoint_name='put-link', 244 | description="I am not a bookmark about a link.", 245 | private=False, 246 | tags=["nope", "4891"], 247 | title="Ain't Talkin' 'bout Links", 248 | url='https://aint.talkin.bout.lin.ks' 249 | ) 250 | assert ShaarliV1Client._retrieve_http_params(args) == \ 251 | ( 252 | 'PUT', 253 | 'links/46', 254 | { 255 | 'description': "I am not a bookmark about a link.", 256 | 'private': False, 257 | 'tags': ["nope", "4891"], 258 | 'title': "Ain't Talkin' 'bout Links", 259 | 'url': 'https://aint.talkin.bout.lin.ks' 260 | } 261 | ) 262 | 263 | 264 | def test_retrieve_http_params_put_empty_link(): 265 | """Retrieve REST parameters from an Argparse Namespace - PUT /links""" 266 | args = Namespace( 267 | resource=485, 268 | endpoint_name='put-link' 269 | ) 270 | assert ShaarliV1Client._retrieve_http_params(args) == \ 271 | ('PUT', 'links/485', {}) 272 | 273 | 274 | def test_retrieve_http_params_get_tags(): 275 | """Retrieve REST parameters from an Argparse Namespace - GET /tags""" 276 | args = Namespace( 277 | endpoint_name='get-tags', 278 | offset=42, 279 | limit='all', 280 | visibility='public' 281 | ) 282 | assert ShaarliV1Client._retrieve_http_params(args) == \ 283 | ('GET', 'tags', {'offset': 42, 'limit': 'all', 'visibility': 'public'}) 284 | 285 | 286 | @mock.patch('requests.request') 287 | def test_get_tags_uri(request): 288 | """Ensure the proper endpoint URI is accessed""" 289 | ShaarliV1Client(SHAARLI_URL, SHAARLI_SECRET).get_tags({}) 290 | request.assert_called_once_with( 291 | 'GET', 292 | '%s/api/v1/tags' % SHAARLI_URL, 293 | headers=mock.ANY, 294 | verify=True, 295 | params={} 296 | ) 297 | 298 | 299 | @mock.patch('requests.request') 300 | def test_put_tags_uri(request): 301 | """Ensure the proper endpoint URI is accessed""" 302 | ShaarliV1Client(SHAARLI_URL, SHAARLI_SECRET).put_tag('some-tag', {}) 303 | request.assert_called_once_with( 304 | 'PUT', 305 | '%s/api/v1/tags/some-tag' % SHAARLI_URL, 306 | headers=mock.ANY, 307 | verify=True, 308 | json={} 309 | ) 310 | 311 | 312 | def test_retrieve_http_params_put_tag(): 313 | """Retrieve REST parameters from an Argparse Namespace - PUT /tags""" 314 | args = Namespace( 315 | resource='some-tag', 316 | endpoint_name='put-tag', 317 | name='new-tag', 318 | ) 319 | assert ShaarliV1Client._retrieve_http_params(args) == \ 320 | ( 321 | 'PUT', 322 | 'tags/some-tag', 323 | { 324 | 'name': 'new-tag', 325 | } 326 | ) 327 | 328 | 329 | def test_retrieve_http_params_put_empty_tag(): 330 | """Retrieve REST parameters from an Argparse Namespace - PUT /tags""" 331 | args = Namespace( 332 | resource='some-tag', 333 | endpoint_name='put-tag' 334 | ) 335 | assert ShaarliV1Client._retrieve_http_params(args) == \ 336 | ('PUT', 'tags/some-tag', {}) 337 | 338 | 339 | @mock.patch('requests.request') 340 | def test_delete_tags_uri(request): 341 | """Ensure the proper endpoint URI is accessed""" 342 | ShaarliV1Client(SHAARLI_URL, SHAARLI_SECRET).delete_tag('some-tag', {}) 343 | request.assert_called_once_with( 344 | 'DELETE', 345 | '%s/api/v1/tags/some-tag' % SHAARLI_URL, 346 | headers=mock.ANY, 347 | verify=True, 348 | json={} 349 | ) 350 | 351 | 352 | @mock.patch('requests.request') 353 | def test_delete_link_uri(request): 354 | """Ensure the proper endpoint URI is accessed""" 355 | ShaarliV1Client(SHAARLI_URL, SHAARLI_SECRET).delete_link(1234, {}) 356 | request.assert_called_once_with( 357 | 'DELETE', 358 | '%s/api/v1/links/1234' % SHAARLI_URL, 359 | headers=mock.ANY, 360 | verify=True, 361 | json={} 362 | ) 363 | 364 | 365 | def test_retrieve_http_params_delete_tag(): 366 | """Retrieve REST parameters from an Argparse Namespace - DELETE /tags""" 367 | args = Namespace( 368 | resource='some-tag', 369 | endpoint_name='delete-tag', 370 | ) 371 | assert ShaarliV1Client._retrieve_http_params(args) == \ 372 | ( 373 | 'DELETE', 374 | 'tags/some-tag', 375 | {} 376 | ) 377 | 378 | 379 | def test_retrieve_http_params_delete_empty_tag(): 380 | """Retrieve REST parameters from an Argparse Namespace - DELETE /tags""" 381 | args = Namespace( 382 | resource='some-tag', 383 | endpoint_name='delete-tag' 384 | ) 385 | assert ShaarliV1Client._retrieve_http_params(args) == \ 386 | ('DELETE', 'tags/some-tag', {}) 387 | -------------------------------------------------------------------------------- /tests/test_config.py: -------------------------------------------------------------------------------- 1 | """Tests for Shaarli configuration utilities""" 2 | # pylint: disable=invalid-name,redefined-outer-name 3 | from argparse import Namespace 4 | from configparser import ConfigParser 5 | 6 | import pytest 7 | 8 | from shaarli_client.config import InvalidConfiguration, get_credentials 9 | 10 | SHAARLI_URL = 'http://shaar.li' 11 | SHAARLI_SECRET = 's3kr37' 12 | 13 | 14 | @pytest.fixture(scope='session') 15 | def shaarli_config(tmpdir_factory): 16 | """Generate a client configuration file""" 17 | config = ConfigParser() 18 | config['shaarli'] = { 19 | 'url': SHAARLI_URL, 20 | 'secret': SHAARLI_SECRET 21 | } 22 | config['shaarli:shaaplin'] = { 23 | 'url': SHAARLI_URL, 24 | 'secret': SHAARLI_SECRET 25 | } 26 | config['shaarli:nourl'] = { 27 | 'secret': SHAARLI_SECRET 28 | } 29 | config['shaarli:nosecret'] = { 30 | 'url': SHAARLI_URL, 31 | } 32 | 33 | config_path = tmpdir_factory.mktemp('config').join('shaarli_client.ini') 34 | 35 | with config_path.open('w') as f_config: 36 | config.write(f_config) 37 | 38 | return config_path 39 | 40 | 41 | def test_get_credentials_from_cli(): 42 | """Get authentication information as CLI parameters""" 43 | url, secret = get_credentials( 44 | Namespace(url=SHAARLI_URL, secret=SHAARLI_SECRET) 45 | ) 46 | assert url == SHAARLI_URL 47 | assert secret == SHAARLI_SECRET 48 | 49 | 50 | @pytest.mark.parametrize('instance', [None, 'shaaplin']) 51 | def test_get_credentials_from_config(tmpdir, shaarli_config, instance): 52 | """Read credentials from a standard location""" 53 | with tmpdir.as_cwd(): 54 | shaarli_config.copy(tmpdir.join('shaarli_client.ini')) 55 | 56 | url, secret = get_credentials( 57 | Namespace( 58 | config=None, 59 | instance=instance, 60 | url=None, 61 | secret=None 62 | ) 63 | ) 64 | 65 | assert url == SHAARLI_URL 66 | assert secret == SHAARLI_SECRET 67 | 68 | 69 | @pytest.mark.parametrize('instance', [None, 'shaaplin']) 70 | def test_get_credentials_from_userconfig(shaarli_config, instance): 71 | """Read credentials from a user-provided configuration file""" 72 | url, secret = get_credentials( 73 | Namespace( 74 | config=str(shaarli_config), 75 | instance=instance, 76 | url=None, 77 | secret=None 78 | ) 79 | ) 80 | assert url == SHAARLI_URL 81 | assert secret == SHAARLI_SECRET 82 | 83 | 84 | def test_get_credentials_no_config(monkeypatch): 85 | """No configuration file found""" 86 | monkeypatch.setattr(ConfigParser, 'read', lambda x, y: []) 87 | 88 | with pytest.raises(InvalidConfiguration) as exc: 89 | get_credentials( 90 | Namespace( 91 | config=None, 92 | instance=None, 93 | url=None, 94 | secret=None 95 | ) 96 | ) 97 | assert "No configuration file found" in str(exc.value) 98 | 99 | 100 | def test_get_credentials_missing_section(shaarli_config): 101 | """The specified instance has no configuration section""" 102 | with pytest.raises(InvalidConfiguration) as exc: 103 | get_credentials( 104 | Namespace( 105 | config=str(shaarli_config), 106 | instance='nonexistent', 107 | url=None, 108 | secret=None 109 | ) 110 | ) 111 | assert "Missing entry: 'shaarli:nonexistent'" in str(exc.value) 112 | 113 | 114 | @pytest.mark.parametrize('attribute', ['url', 'secret']) 115 | def test_get_credentials_missing_attribute(shaarli_config, attribute): 116 | """The specified instance has no configuration section""" 117 | with pytest.raises(InvalidConfiguration) as exc: 118 | get_credentials( 119 | Namespace( 120 | config=str(shaarli_config), 121 | instance='no{}'.format(attribute), 122 | url=None, 123 | secret=None 124 | ) 125 | ) 126 | assert "Missing entry: '{}'".format(attribute) in str(exc.value) 127 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | """Tests for Shaarli client utilities""" 2 | # pylint: disable=invalid-name 3 | import json 4 | from argparse import ArgumentParser 5 | from unittest import mock 6 | 7 | import pytest 8 | from requests import Response 9 | 10 | from shaarli_client.utils import format_response, generate_endpoint_parser 11 | 12 | 13 | @mock.patch('argparse.ArgumentParser.add_argument') 14 | def test_generate_endpoint_parser_noparam(addargument): 15 | """Generate a parser from endpoint metadata - no params""" 16 | name = 'put-stuff' 17 | metadata = { 18 | 'path': 'stuff', 19 | 'method': 'PUT', 20 | 'help': "Changes stuff", 21 | 'params': {}, 22 | } 23 | parser = ArgumentParser() 24 | subparsers = parser.add_subparsers() 25 | 26 | generate_endpoint_parser(subparsers, name, metadata) 27 | 28 | addargument.assert_has_calls([ 29 | # first helper for the main parser 30 | mock.call('-h', '--help', action='help', 31 | default=mock.ANY, help=mock.ANY), 32 | 33 | # second helper for the 'put-stuff' subparser 34 | mock.call('-h', '--help', action='help', 35 | default=mock.ANY, help=mock.ANY) 36 | ]) 37 | 38 | 39 | @mock.patch('argparse.ArgumentParser.add_argument') 40 | def test_generate_endpoint_parser_single_param(addargument): 41 | """Generate a parser from endpoint metadata - single param""" 42 | name = 'get-stuff' 43 | metadata = { 44 | 'path': 'stuff', 45 | 'method': 'GET', 46 | 'help': "Gets stuff", 47 | 'params': { 48 | 'param1': { 49 | 'help': "First param", 50 | }, 51 | }, 52 | } 53 | parser = ArgumentParser() 54 | subparsers = parser.add_subparsers() 55 | 56 | generate_endpoint_parser(subparsers, name, metadata) 57 | 58 | addargument.assert_has_calls([ 59 | # first helper for the main parser 60 | mock.call('-h', '--help', action='help', 61 | default=mock.ANY, help=mock.ANY), 62 | 63 | # second helper for the 'put-stuff' subparser 64 | mock.call('-h', '--help', action='help', 65 | default=mock.ANY, help=mock.ANY), 66 | 67 | # param1 68 | mock.call('--param1', help="First param") 69 | ]) 70 | 71 | 72 | @mock.patch('argparse.ArgumentParser.add_argument') 73 | def test_generate_endpoint_parser_multi_param(addargument): 74 | """Generate a parser from endpoint metadata - multiple params""" 75 | name = 'get-stuff' 76 | metadata = { 77 | 'path': 'stuff', 78 | 'method': 'GET', 79 | 'help': "Gets stuff", 80 | 'params': { 81 | 'param1': { 82 | 'help': "First param", 83 | 'type': int, 84 | }, 85 | 'param2': { 86 | 'choices': ['a', 'b', 'c'], 87 | 'help': "Second param", 88 | 'nargs': '+', 89 | }, 90 | }, 91 | } 92 | parser = ArgumentParser() 93 | subparsers = parser.add_subparsers() 94 | 95 | generate_endpoint_parser(subparsers, name, metadata) 96 | 97 | addargument.assert_has_calls([ 98 | # first helper for the main parser 99 | mock.call('-h', '--help', action='help', 100 | default=mock.ANY, help=mock.ANY), 101 | 102 | # second helper for the 'put-stuff' subparser 103 | mock.call('-h', '--help', action='help', 104 | default=mock.ANY, help=mock.ANY), 105 | 106 | # param1 107 | mock.call('--param1', help="First param", type=int), 108 | 109 | # param2 110 | mock.call('--param2', choices=['a', 'b', 'c'], 111 | help="Second param", nargs='+') 112 | ]) 113 | 114 | 115 | @mock.patch('argparse.ArgumentParser.add_argument') 116 | def test_generate_endpoint_parser_resource(addargument): 117 | """Generate a parser from endpoint metadata - API resource""" 118 | name = 'get-stuff' 119 | metadata = { 120 | 'path': 'stuff', 121 | 'method': 'GET', 122 | 'help': "Gets stuff", 123 | 'resource': { 124 | 'help': "API resource", 125 | 'type': int, 126 | }, 127 | 'params': {}, 128 | } 129 | parser = ArgumentParser() 130 | subparsers = parser.add_subparsers() 131 | 132 | generate_endpoint_parser(subparsers, name, metadata) 133 | 134 | addargument.assert_has_calls([ 135 | # first helper for the main parser 136 | mock.call('-h', '--help', action='help', 137 | default=mock.ANY, help=mock.ANY), 138 | 139 | # second helper for the 'put-stuff' subparser 140 | mock.call('-h', '--help', action='help', 141 | default=mock.ANY, help=mock.ANY), 142 | 143 | # resource 144 | mock.call('resource', help="API resource", type=int) 145 | ]) 146 | 147 | 148 | def test_format_response_unsupported_format(): 149 | """Attempt to use an unsupported formatting flag""" 150 | response = Response() 151 | response.__setstate__({'_content': b'{"field":"value"}'}) 152 | 153 | with pytest.raises(ValueError) as err: 154 | format_response('xml', response) 155 | 156 | assert "not a supported format" in str(err.value) 157 | 158 | 159 | @pytest.mark.parametrize('output_format', ['json', 'pprint', 'text']) 160 | def test_format_response_empty_body(output_format): 161 | """Format a Requests Response object with no body""" 162 | response = Response() 163 | assert format_response(output_format, response) == '' 164 | 165 | 166 | def test_format_response_text(): 167 | """Format a Requests Response object to plain text""" 168 | response = Response() 169 | response.__setstate__({ 170 | '_content': b'{"global_counter":3251,' 171 | b'"private_counter":1,' 172 | b'"settings":{"title":"Yay!","header_link":"?",' 173 | b'"timezone":"UTC",' 174 | b'"enabled_plugins":["qrcode","token"],' 175 | b'"default_private_links":false}}', 176 | }) 177 | 178 | assert isinstance(response.text, str) 179 | assert response.text == '{"global_counter":3251,' \ 180 | '"private_counter":1,' \ 181 | '"settings":{"title":"Yay!","header_link":"?",' \ 182 | '"timezone":"UTC",' \ 183 | '"enabled_plugins":["qrcode","token"],' \ 184 | '"default_private_links":false}}' 185 | 186 | 187 | def test_format_response_json(): 188 | """Format a Requests Response object to JSON""" 189 | response = Response() 190 | response.__setstate__({ 191 | '_content': b'{"global_counter":3251,' 192 | b'"private_counter":1,' 193 | b'"settings":{"title":"Yay!","header_link":"?",' 194 | b'"timezone":"UTC",' 195 | b'"enabled_plugins":["qrcode","token"],' 196 | b'"default_private_links":false}}', 197 | }) 198 | 199 | assert isinstance(response.json(), dict) 200 | 201 | # Ensure valid JSON is returned after formatting 202 | assert json.loads(format_response('json', response)) 203 | assert json.loads(format_response('pprint', response)) 204 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py3.{6,7,8,9},docs 3 | skip_missing_interpreters=True 4 | 5 | [testenv] 6 | deps=-rrequirements/tests.txt 7 | commands= 8 | isort --check --diff shaarli_client 9 | pycodestyle 10 | pydocstyle 11 | pytest --pylint 12 | pytest --cov=shaarli_client 13 | 14 | [testenv:docs] 15 | basepython=python3 16 | deps=-rrequirements/docs.txt 17 | whitelist_externals=rm 18 | commands= 19 | rm -rf docs/_build 20 | sphinx-build -aEnq docs docs/_build/html 21 | sphinx-build -aEnQW docs docs/_build/html 22 | rst2html.py --strict README.rst /dev/null 23 | rst2html.py --strict docs/changelog.rst /dev/null 24 | --------------------------------------------------------------------------------