├── docs ├── reqs.txt ├── Makefile ├── pyclickup │ └── quickstart.rst ├── index.rst └── conf.py ├── .envrc ├── requirements.txt ├── MANIFEST.in ├── pyclickup ├── test │ ├── __init__.py │ ├── conftest.py │ ├── helpers.py │ └── test_pyclickup.py ├── utils │ ├── __init__.py │ └── text.py ├── __init__.py ├── models │ ├── error.py │ ├── client.py │ └── __init__.py └── globals.py ├── requirements.dev.txt ├── setup.cfg ├── tox.ini ├── Makefile ├── .github └── workflows │ ├── deploy.yml │ └── test.yml ├── default.nix ├── LICENSE ├── README.md ├── .prospector.yaml ├── setup.py └── .gitignore /docs/reqs.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | use nix 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include requirements.txt 3 | -------------------------------------------------------------------------------- /pyclickup/test/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pytest files 3 | """ 4 | -------------------------------------------------------------------------------- /pyclickup/utils/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | utilities for pyclickup 3 | """ 4 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | colorama 2 | pytest 3 | pytest-cov 4 | tox 5 | types-requests 6 | -------------------------------------------------------------------------------- /pyclickup/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | pyclickup main entrypoint for the library 3 | """ 4 | from pyclickup.models.client import ClickUp # noqa 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [mypy] 2 | python_version = 3.9 3 | disallow_untyped_defs = False 4 | ignore_missing_imports = True 5 | 6 | [flake8] 7 | ignore = N802,N807,W503 8 | max-line-length = 100 9 | max-complexity = 20 10 | 11 | [tool:pytest] 12 | log_print = False 13 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py{36,37,38,39,310} 3 | 4 | [testenv] 5 | setenv = 6 | PYTHONPATH = {toxinidir} 7 | deps = 8 | -r{toxinidir}/requirements.txt 9 | -r{toxinidir}/requirements.dev.txt 10 | commands = 11 | pytest -s --cov pyclickup --cov-report term --cov-report html 12 | -------------------------------------------------------------------------------- /pyclickup/test/conftest.py: -------------------------------------------------------------------------------- 1 | """ 2 | configure pytest 3 | """ 4 | import pytest 5 | from pyclickup.test.helpers import dbg 6 | from typing import Any 7 | 8 | 9 | @pytest.fixture(scope="session", autouse=True) 10 | def before_all(request: Any) -> None: 11 | """test setup""" 12 | dbg("[+] begin pyclickup tests") 13 | request.addfinalizer(after_all) 14 | 15 | 16 | def after_all() -> None: 17 | """tear down""" 18 | dbg("[+] end pyclickup tests") 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | repo = pyclickup 2 | base_command = pytest 3 | coverage = --cov-config setup.cfg --cov=$(repo) 4 | html_report = --cov-report html 5 | term_report = --cov-report term 6 | xml_report = --cov-report xml 7 | reports = $(html_report) $(term_report) $(xml_report) 8 | target = --target-version py34 9 | 10 | 11 | all: test_all 12 | 13 | test_all: 14 | @$(base_command) $(coverage) $(reports) -s --pyargs $(repo) 15 | 16 | check_format: 17 | @black $(target) --check $(repo)/ 18 | 19 | format: 20 | @black $(target) $(repo)/ 21 | 22 | .PHONY: test_all check_format format 23 | -------------------------------------------------------------------------------- /pyclickup/test/helpers.py: -------------------------------------------------------------------------------- 1 | """ 2 | helpers for the pytest suite 3 | """ 4 | import json 5 | import sys 6 | from colorama import init, Fore, Style 7 | 8 | 9 | init() 10 | 11 | 12 | def dbg(text: str) -> None: 13 | """debug printer for tests""" 14 | if isinstance(text, dict): 15 | text = json.dumps(text, sort_keys=True, indent=2) 16 | caller = sys._getframe(1) 17 | print("") 18 | print(Fore.GREEN + Style.BRIGHT) 19 | print("----- {} line {} ------".format(caller.f_code.co_name, caller.f_lineno)) 20 | print(text) 21 | print("-----") 22 | print(Style.RESET_ALL) 23 | print("") 24 | -------------------------------------------------------------------------------- /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 = pyclickup 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) -------------------------------------------------------------------------------- /pyclickup/models/error.py: -------------------------------------------------------------------------------- 1 | """ 2 | error models for pyclickup 3 | """ 4 | from typing import Any 5 | 6 | 7 | class PyClickUpException(Exception): 8 | """base pyclickup exception class""" 9 | 10 | def __init__(self, *args: Any, **kwargs: Any) -> None: 11 | extra = "" 12 | if args: 13 | extra = f'\n| extra info: "{args[0]}"' 14 | print(f"[{self.__class__.__name__}]: {self.__doc__}{extra}") 15 | Exception.__init__(self, *args) 16 | 17 | 18 | class RateLimited(PyClickUpException): 19 | """request received a 429 - you are currently rate limited""" 20 | 21 | 22 | class MissingClient(PyClickUpException): 23 | """no client set for this object""" 24 | -------------------------------------------------------------------------------- /pyclickup/globals.py: -------------------------------------------------------------------------------- 1 | """ 2 | globals 3 | """ 4 | 5 | 6 | __version__ = "0.1.4" 7 | 8 | 9 | LIBRARY = "pyclickup" 10 | API_URL = "https://api.clickup.com/api/v1/" 11 | 12 | 13 | TEST_API_URL = "https://private-anon-efe850a7d7-clickup.apiary-mock.com/api/v1/" 14 | TEST_TOKEN = "access_token" # nosec 15 | 16 | 17 | DEFAULT_STATUSES = [ 18 | {"status": "Open", "type": "open", "orderindex": 0, "color": "#d3d3d3"}, 19 | {"status": "todo", "type": "custom", "orderindex": 1, "color": "#ff00df"}, 20 | {"status": "in progress", "type": "custom", "orderindex": 2, "color": "#f6762b"}, 21 | {"status": "in review", "type": "custom", "orderindex": 3, "color": "#08adff"}, 22 | {"status": "Closed", "type": "closed", "orderindex": 4, "color": "#6bc950"}, 23 | ] 24 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Upload Python Package 3 | 4 | on: 5 | release: 6 | types: [created] 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: set up python 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: '3.x' 17 | - name: install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install setuptools wheel twine 21 | sed -i -E "s#VERSION#${GITHUB_REF/refs\/tags\//}#g" ./setup.py 22 | - name: build and publish 23 | env: 24 | TWINE_USERNAME: ${{ secrets.PYPI_USER }} 25 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 26 | run: | 27 | python setup.py sdist bdist_wheel 28 | twine upload dist/* 29 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | { jacobi ? import 2 | ( 3 | fetchTarball { 4 | name = "jpetrucciani-2022-08-17"; 5 | url = "https://github.com/jpetrucciani/nix/archive/93ad4046a5fe9924282af9c56dc88012d8b5315e.tar.gz"; 6 | sha256 = "1ps5d55qvmh66n3xlmm1avsczl6xz82xic2n3vqrd1aj31kzdkm3"; 7 | } 8 | ) 9 | { } 10 | }: 11 | let 12 | inherit (jacobi.hax) ifIsLinux ifIsDarwin; 13 | 14 | name = "pyclickup"; 15 | tools = with jacobi; { 16 | cli = [ 17 | jq 18 | nixpkgs-fmt 19 | ]; 20 | python = [ 21 | (python310.withPackages (p: with p; [ 22 | requests 23 | 24 | # dev 25 | colorama 26 | pytest 27 | pytest-cov 28 | setuptools 29 | tox 30 | types-requests 31 | ])) 32 | ]; 33 | scripts = [ 34 | (writeShellScriptBin "test_actions" '' 35 | ${jacobi.act}/bin/act --artifact-server-path ./.cache/ -r --rm 36 | '') 37 | ]; 38 | }; 39 | 40 | env = jacobi.enviro { 41 | inherit name tools; 42 | }; 43 | in 44 | env 45 | -------------------------------------------------------------------------------- /docs/pyclickup/quickstart.rst: -------------------------------------------------------------------------------- 1 | .. _quickstart: 2 | 3 | Quickstart 4 | ========== 5 | 6 | This document presents a brief, high-level overview of pyclickup's primary features. 7 | 8 | pyclickup is a python wrapper for the ClickUp API 9 | 10 | .. note:: 11 | Be aware that this uses the `ClickUP API `_ directly. The ClickUp API is currently in beta, and is subject to change. 12 | 13 | At the time of writing, ClickUp has the following limits in place for API requests: 14 | 15 | - 100 requests per minute per token 16 | 17 | Installation 18 | ------------ 19 | 20 | .. code-block:: bash 21 | 22 | # install pyclickup 23 | pip install pyclickup 24 | 25 | 26 | Basic Usage 27 | ----------- 28 | 29 | .. code-block:: python 30 | 31 | from pyclickup import ClickUp 32 | 33 | 34 | clickup = ClickUp('$ACCESS_TOKEN') 35 | 36 | main_team = clickup.teams[0] 37 | main_space = main_team.spaces[0] 38 | members = main_space.members 39 | 40 | main_project = main_space.projects[0] 41 | main_list = main_project.lists[0] 42 | 43 | tasks = main_list.get_all_tasks(include_closed=True) 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Jacobi Petrucciani 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 | -------------------------------------------------------------------------------- /pyclickup/utils/text.py: -------------------------------------------------------------------------------- 1 | """ 2 | text manipulation utilities 3 | """ 4 | import re 5 | from datetime import datetime 6 | from typing import Any 7 | 8 | 9 | FIRST_CAP = re.compile("(.)([A-Z][a-z]+)") 10 | ALL_CAP = re.compile("([a-z0-9])([A-Z])") 11 | LOCALS_FILTER = ["self", "kwargs"] 12 | 13 | 14 | def snakeify(text: str) -> str: 15 | """camelCase to snake_case""" 16 | first_string = FIRST_CAP.sub(r"\1_\2", text) 17 | return ALL_CAP.sub(r"\1_\2", first_string).lower() 18 | 19 | 20 | def ts_to_datetime(timestamp: int) -> datetime: 21 | """converts the posix x1000 timestamp to a python datetime""" 22 | return datetime.utcfromtimestamp(int(timestamp) / 1000) 23 | 24 | 25 | def datetime_to_ts(date_object: datetime) -> int: 26 | """converts a datetime to a posix x1000 timestamp""" 27 | return int(date_object.timestamp() * 1000) 28 | 29 | 30 | def filter_locals(local_variables: Any, extras: list = None) -> dict: 31 | """filters out builtin variables in the local scope and returns locals as a dict""" 32 | var_filter = LOCALS_FILTER.copy() 33 | if extras and isinstance(extras, list): 34 | var_filter += extras 35 | return { 36 | x: local_variables[x] 37 | for x in local_variables 38 | if local_variables[x] is not None and x not in var_filter 39 | } 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pyclickup 2 | 3 | [![PyPI version](https://badge.fury.io/py/pyclickup.svg)](https://badge.fury.io/py/pyclickup) 4 | [![Build Status](https://travis-ci.org/jpetrucciani/pyclickup.svg?branch=master)](https://travis-ci.org/jpetrucciani/pyclickup) 5 | [![Coverage Status](https://coveralls.io/repos/github/jpetrucciani/pyclickup/badge.svg?branch=master)](https://coveralls.io/github/jpetrucciani/pyclickup?branch=master) 6 | [![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/ambv/black) 7 | [![Documentation Status](https://readthedocs.org/projects/pyclickup/badge/?version=latest)](https://pyclickup.readthedocs.io/en/latest/?badge=latest) 8 | [![Python 3.6+ supported](https://img.shields.io/badge/python-3.6+-blue.svg)](https://www.python.org/downloads/release/python-360/) 9 | 10 | A python wrapper for the ClickUp API 11 | 12 | ## Quick start 13 | 14 | ### Installation 15 | 16 | ``` bash 17 | # install pyclickup 18 | pip install pyclickup 19 | ``` 20 | 21 | ### Basic Usage 22 | 23 | ``` python 24 | from pyclickup import ClickUp 25 | 26 | 27 | clickup = ClickUp("$ACCESS_TOKEN") 28 | 29 | main_team = clickup.teams[0] 30 | main_space = main_team.spaces[0] 31 | members = main_space.members 32 | 33 | main_project = main_space.projects[0] 34 | main_list = main_project.lists[0] 35 | 36 | tasks = main_list.get_all_tasks(include_closed=True) 37 | ``` 38 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | workflow_dispatch: 11 | 12 | jobs: 13 | prospector: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | - uses: jpetrucciani/prospector-check@master 18 | mypy: 19 | runs-on: ubuntu-latest 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: jpetrucciani/mypy-check@master 23 | with: 24 | requirements_file: requirements.dev.txt 25 | black: 26 | runs-on: ubuntu-latest 27 | steps: 28 | - uses: actions/checkout@v2 29 | - uses: jpetrucciani/black-check@master 30 | with: 31 | path: 'pyclickup/' 32 | ### These are disabled as the old mock server seems to no longer be up 33 | # tests: 34 | # runs-on: ubuntu-latest 35 | # needs: [mypy, prospector, black] 36 | # strategy: 37 | # matrix: 38 | # python-version: ['3.6', '3.7', '3.8', '3.9', '3.10'] 39 | # name: python ${{ matrix.python-version }} tests 40 | # steps: 41 | # - uses: actions/checkout@v2 42 | # - name: setup python 43 | # uses: actions/setup-python@v2 44 | # with: 45 | # python-version: ${{ matrix.python-version }} 46 | # architecture: x64 47 | # - name: install requirements 48 | # run: | 49 | # pip install -r requirements.txt 50 | # pip install -r requirements.dev.txt 51 | # - name: run Tox 52 | # run: tox -e py 53 | -------------------------------------------------------------------------------- /.prospector.yaml: -------------------------------------------------------------------------------- 1 | strictness: veryhigh 2 | doc-warnings: true 3 | member-warnings: false 4 | test-warnings: false 5 | 6 | ignore-patterns: 7 | - (^|/)\..+ 8 | - .*\.html 9 | - docs/.* 10 | - tmp.py 11 | - setup.py 12 | 13 | pylint: 14 | disable: 15 | - bad-continuation 16 | - broad-except 17 | - import-error 18 | - import-self 19 | - protected-access 20 | - logging-format-interpolation 21 | - missing-docstring 22 | - no-self-use 23 | - unused-argument 24 | - wrong-import-order 25 | 26 | options: 27 | max-args: 20 28 | max-locals: 100 29 | max-returns: 6 30 | max-branches: 50 31 | max-statements: 180 32 | max-parents: 10 33 | max-attributes: 10 34 | min-public-methods: 0 35 | max-public-methods: 20 36 | max-module-lines: 2000 37 | max-line-length: 100 38 | 39 | mccabe: 40 | options: 41 | max-complexity: 30 42 | 43 | pycodestyle: 44 | disable: 45 | - N802 46 | - N807 47 | - N818 48 | - W503 49 | - W605 50 | options: 51 | max-line-length: 100 52 | single-line-if-stmt: n 53 | 54 | vulture: 55 | run: false 56 | 57 | pyroma: 58 | run: false 59 | 60 | bandit: 61 | run: true 62 | 63 | pydocstyle: 64 | disable: 65 | - D000 66 | - D100 67 | - D104 68 | - D107 69 | - D200 70 | - D202 71 | - D203 72 | - D205 73 | - D212 74 | - D204 75 | - D300 76 | - D400 77 | - D401 78 | - D404 79 | - D403 80 | - D415 81 | 82 | pyflakes: 83 | disable: 84 | - F401 85 | - F403 86 | - F999 87 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """ 3 | pip setup file 4 | """ 5 | from setuptools import setup, find_packages 6 | 7 | 8 | __library__ = "pyclickup" 9 | __version__ = "VERSION" 10 | 11 | __user__ = "https://github.com/jpetrucciani" 12 | 13 | 14 | with open("README.md") as readme: 15 | LONG_DESCRIPTION = readme.read() 16 | 17 | 18 | with open("requirements.txt") as requirements: 19 | INSTALL_REQUIRES = requirements.read().split("\n") 20 | INSTALL_REQUIRES = [x.strip() for x in INSTALL_REQUIRES if x.strip()] 21 | 22 | 23 | setup( 24 | name=__library__, 25 | version=__version__, 26 | description="A python wrapper for the ClickUp API", 27 | long_description=LONG_DESCRIPTION, 28 | long_description_content_type="text/markdown", 29 | author="Jacobi Petrucciani", 30 | author_email="j@cobi.dev", 31 | keywords="clickup python", 32 | url=f"{__user__}/{__library__}.git", 33 | download_url=f"{__user__}/{__library__}.git", 34 | license="MIT", 35 | packages=find_packages(), 36 | install_requires=INSTALL_REQUIRES, 37 | classifiers=[ 38 | "Programming Language :: Python :: 3", 39 | "Programming Language :: Python :: 3.6", 40 | "Programming Language :: Python :: 3.7", 41 | "Programming Language :: Python :: 3.8", 42 | "Programming Language :: Python :: 3.9", 43 | "Programming Language :: Python :: 3.10", 44 | "Programming Language :: Python :: Implementation :: CPython", 45 | "Programming Language :: Python :: Implementation :: PyPy", 46 | ], 47 | zip_safe=False, 48 | ) 49 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. pyclickup documentation master file, created by 2 | sphinx-quickstart on Wed Aug 8 18:45:39 2018. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | pyclickup 7 | ======== 8 | 9 | 10 | .. image:: https://badge.fury.io/py/pyclickup.svg 11 | :target: https://badge.fury.io/py/pyclickup 12 | :alt: PyPI version 13 | 14 | 15 | .. image:: https://travis-ci.org/jpetrucciani/pyclickup.svg?branch=master 16 | :target: https://travis-ci.org/jpetrucciani/pyclickup 17 | :alt: Build Status 18 | 19 | 20 | .. image:: https://coveralls.io/repos/github/jpetrucciani/pyclickup/badge.svg?branch=master 21 | :target: https://coveralls.io/github/jpetrucciani/pyclickup?branch=master 22 | :alt: Coverage Status 23 | 24 | 25 | .. image:: https://img.shields.io/badge/code%20style-black-000000.svg 26 | :target: https://github.com/ambv/black 27 | :alt: Code style: black 28 | 29 | 30 | .. image:: https://readthedocs.org/projects/pyclickup/badge/?version=latest 31 | :target: https://pyclickup.readthedocs.io/en/latest/?badge=latest 32 | :alt: Documentation Status 33 | 34 | 35 | A python wrapper for the ClickUp API 36 | 37 | 38 | pyclickup's source code hosted on `GitHub `_. 39 | 40 | New to pyclickup? These may help: 41 | 42 | 43 | .. toctree:: 44 | :maxdepth: 2 45 | :glob: 46 | 47 | pyclickup/quickstart 48 | 49 | 50 | Note 51 | ---- 52 | 53 | If you find any bugs, odd behavior, or have an idea for a new feature please don't hesitate to open an issue! 54 | 55 | 56 | Indices and tables 57 | ================== 58 | 59 | * :ref:`genindex` 60 | * :ref:`modindex` 61 | * :ref:`search` 62 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | tmp.py 2 | 3 | ### Python ### 4 | # Byte-compiled / optimized / DLL files 5 | __pycache__/ 6 | *.py[cod] 7 | *$py.class 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # celery beat schedule file 86 | celerybeat-schedule 87 | 88 | # SageMath parsed files 89 | *.sage.py 90 | 91 | # Environments 92 | .env 93 | .venv 94 | env/ 95 | venv/ 96 | ENV/ 97 | env.bak/ 98 | venv.bak/ 99 | 100 | # Spyder project settings 101 | .spyderproject 102 | .spyproject 103 | 104 | # Rope project settings 105 | .ropeproject 106 | 107 | # mkdocs documentation 108 | /site 109 | 110 | # mypy 111 | .mypy_cache/ 112 | .dmypy.json 113 | dmypy.json 114 | 115 | ### Python Patch ### 116 | .venv/ 117 | 118 | ### Python.VirtualEnv Stack ### 119 | # Virtualenv 120 | # http://iamzed.com/2009/05/07/a-primer-on-virtualenv/ 121 | [Bb]in 122 | [Ii]nclude 123 | [Ll]ib 124 | [Ll]ib64 125 | [Ll]ocal 126 | [Ss]cripts 127 | pyvenv.cfg 128 | pip-selfcheck.json 129 | 130 | # nix stuff 131 | .direnv 132 | result* 133 | -------------------------------------------------------------------------------- /pyclickup/test/test_pyclickup.py: -------------------------------------------------------------------------------- 1 | """ 2 | a base test suite for pyclickup 3 | """ 4 | from datetime import datetime 5 | from pyclickup.models import ( 6 | LIBRARY, 7 | List, 8 | Project, 9 | Space, 10 | Status, 11 | Tag, 12 | Task, 13 | Team, 14 | User, 15 | ) 16 | from pyclickup.models.client import test_client, ClickUp 17 | from pyclickup.globals import __version__, TEST_TOKEN 18 | 19 | 20 | CLICKUP = test_client() 21 | 22 | 23 | def is_list_of_type(check_list, check_type): 24 | """helper function for checking if it's a list and of a specific type""" 25 | assert isinstance(check_list, list) 26 | assert isinstance(check_list[0], check_type) 27 | return True 28 | 29 | 30 | def test_user_agent(): 31 | """tests the default user agent""" 32 | headers = CLICKUP.headers 33 | assert isinstance(headers, (dict,)) 34 | assert headers["User-Agent"] == "{}/{}".format(LIBRARY, __version__) 35 | 36 | 37 | def test_custom_user_agent(): 38 | """tests the custom user agent""" 39 | test_user_agent = "brwnppr/0.96" 40 | headers = ClickUp(token=TEST_TOKEN, user_agent=test_user_agent).headers 41 | assert isinstance(headers, (dict,)) 42 | assert headers["User-Agent"] == test_user_agent 43 | 44 | 45 | def test_user(): 46 | """testing the user property""" 47 | user = CLICKUP.user 48 | assert user 49 | assert isinstance(user, User) 50 | assert user.id == 123 51 | assert user.username == "John Doe" 52 | assert user.color 53 | assert user.profile_picture 54 | assert " None: 35 | """creates a new client""" 36 | if not token: 37 | raise Exception("no token specified!") 38 | self.token = token 39 | self.api_url = api_url 40 | self.version = __version__ 41 | self.cache = cache 42 | self.debug = debug 43 | self.user_agent = user_agent 44 | 45 | # cache 46 | self._user = None # type: Optional[User] 47 | self._teams = None # type: Optional[List[Team]] 48 | 49 | @property 50 | def headers(self) -> dict: 51 | """forms the headers required for the API calls""" 52 | return { 53 | "Accept": "application/json", 54 | "AcceptEncoding": "gzip, deflate", 55 | "Authorization": self.token, 56 | "User-Agent": self.user_agent, 57 | } 58 | 59 | @property 60 | def user(self) -> User: 61 | """get the user associated with this token""" 62 | if not self._user or not self.cache: 63 | self._user = User(self.get("user"), client=self) # type: ignore 64 | return self._user 65 | 66 | @property 67 | def teams(self) -> List[Team]: 68 | """get authorized teams""" 69 | if not self._teams or not self.cache: 70 | teams_data = self.get("team") 71 | if not isinstance(teams_data, dict): 72 | raise Exception("invalid response while looking up teams") 73 | self._teams = [Team(x, client=self) for x in teams_data["teams"]] 74 | return self._teams 75 | 76 | def get_team_by_id(self, team_id: str) -> Team: 77 | """given an team_id, return the team if it exists""" 78 | team_data = self.get(f"team/{team_id}") 79 | if not isinstance(team_data, dict): 80 | raise Exception("no team found") 81 | return Team(team_data["team"], client=self) 82 | 83 | def _log(self, *args: Any) -> None: 84 | """logging method""" 85 | if not self.debug: 86 | return 87 | print(*args) 88 | 89 | def _req(self, path: str, method: str = "get", **kwargs: Any) -> Response: 90 | """requests wrapper""" 91 | full_path = urllib.parse.urljoin(self.api_url, path) 92 | self._log(f"[{method.upper()}]: {full_path}") 93 | request = requests.request(method, full_path, headers=self.headers, **kwargs) 94 | if request.status_code == 429: 95 | raise RateLimited() 96 | return request 97 | 98 | def get( 99 | self, path: str, raw: bool = False, **kwargs: Any 100 | ) -> Union[list, dict, Response]: 101 | """makes a get request to the API""" 102 | request = self._req(path, **kwargs) 103 | return request if raw else request.json() 104 | 105 | def post( 106 | self, path: str, raw: bool = False, **kwargs: Any 107 | ) -> Union[list, dict, Response]: 108 | """makes a post request to the API""" 109 | request = self._req(path, method="post", **kwargs) 110 | return request if raw else request.json() 111 | 112 | def put( 113 | self, path: str, raw: bool = False, **kwargs: Any 114 | ) -> Union[list, dict, Response]: 115 | """makes a put request to the API""" 116 | request = self._req(path, method="put", **kwargs) 117 | return request if raw else request.json() 118 | 119 | def _get_tasks( 120 | self, 121 | team_id: str, 122 | page: int = None, # integer - it appears to fetch 100 at a time 123 | order_by: str = None, # string, [id, created, updated, due_date] 124 | reverse: bool = None, # bool 125 | subtasks: bool = None, # bool 126 | space_ids: list = None, # List 127 | project_ids: list = None, # List 128 | list_ids: list = None, # List 129 | statuses: list = None, # List 130 | include_closed: bool = False, # bool 131 | assignees: list = None, # List 132 | due_date_gt: int = None, # integer, posix time 133 | due_date_lt: int = None, # integer, posix time 134 | date_created_gt: int = None, # integer, posix time 135 | date_created_lt: int = None, # integer, posix time 136 | date_updated_gt: int = None, # integer, posix time 137 | date_updated_lt: int = None, # integer, posix time 138 | **kwargs: Any, 139 | ) -> List[Task]: 140 | """fetches the tasks according to the given options""" 141 | params = filter_locals(locals(), extras=["team_id"]) 142 | 143 | for option in self.task_boolean_options: 144 | if option in params: 145 | params[option] = str(params[option]).lower() 146 | 147 | options = [ 148 | "{}{}={}".format( # pylint: disable=consider-using-f-string 149 | x, 150 | "[]" if x in self.task_list_options else "", 151 | ",".join(params[x]) if x in self.task_list_options else params[x], 152 | ) 153 | for x in params 154 | ] 155 | opts = "&".join(options) 156 | path = f"team/{team_id}/task?{opts}" 157 | task_list = self.get(path) 158 | if not isinstance(task_list, dict): 159 | return [] 160 | return [Task(x, client=self) for x in task_list["tasks"]] 161 | 162 | def _get_all_tasks( 163 | self, team_id: str, page_limit: int = -1, **kwargs: Any 164 | ) -> List[Task]: 165 | """get all tasks wrapper""" 166 | tasks = [] # type: List[Task] 167 | page_count = 0 168 | task_page = self._get_tasks(team_id, page=page_count, **kwargs) 169 | while task_page and (page_limit == -1 or page_count < page_limit): 170 | tasks += task_page 171 | page_count += 1 172 | task_page = self._get_tasks(team_id, page=page_count, **kwargs) 173 | return tasks 174 | 175 | def _create_task( 176 | self, 177 | list_id: str, 178 | name: str, # string 179 | content: str, # string 180 | status: str, # string 181 | assignees: List[Union[int, User]] = None, # list of integers, or user objects 182 | priority: int = None, # integer 183 | due_date: Union[int, datetime] = None, # integer posix time, or python datetime 184 | ) -> Any: 185 | """creates a task in the specified list""" 186 | data = { 187 | "name": name, 188 | "content": content, 189 | "status": status, 190 | } # type: Dict[str, Any] 191 | if assignees: 192 | data["assignees"] = assignees 193 | if priority: 194 | data["priority"] = priority 195 | if due_date: 196 | data["due_date"] = ( 197 | due_date if isinstance(due_date, int) else datetime_to_ts(due_date) 198 | ) 199 | return self.post(f"list/{list_id}/task", data=data) 200 | 201 | 202 | def test_client() -> ClickUp: 203 | """returns a test client""" 204 | return ClickUp(TEST_TOKEN, api_url=TEST_API_URL, debug=True) 205 | -------------------------------------------------------------------------------- /pyclickup/models/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | models for each object in the clickup api 3 | """ 4 | import json 5 | from datetime import datetime 6 | from pyclickup.globals import DEFAULT_STATUSES, LIBRARY 7 | from pyclickup.models.error import MissingClient 8 | from pyclickup.utils.text import snakeify, ts_to_datetime, datetime_to_ts 9 | from requests.models import Response 10 | from typing import Any, Dict, List as ListType, Union # noqa 11 | 12 | 13 | class BaseModel: 14 | """basic model that just parses camelCase json to snake_case keys""" 15 | 16 | def __init__(self, data: dict, client: Any = None, **kwargs: Any) -> None: 17 | """constructor""" 18 | self.id = None # pylint: disable=invalid-name 19 | self._data = {**data, **kwargs} 20 | self._json = self._jsond(data) 21 | self._client = client 22 | 23 | for key in self._data: 24 | setattr(self, snakeify(key), self._data[key]) 25 | 26 | def _jsond(self, json_data: dict) -> str: 27 | """json dumps""" 28 | return json.dumps(json_data) 29 | 30 | def _jsonl(self, dictionary: str) -> dict: 31 | """json loads""" 32 | return json.loads(dictionary) 33 | 34 | 35 | class User(BaseModel): 36 | """user object""" 37 | 38 | def __init__(self, data: dict, **kwargs: Any) -> None: 39 | """override""" 40 | if "user" in data.keys(): 41 | data = data["user"] 42 | super().__init__(data, **kwargs) 43 | 44 | def __repr__(self): 45 | """repr""" 46 | return f"<{LIBRARY}.User[{self.id}] '{self.username}'>" 47 | 48 | 49 | class Status(BaseModel): 50 | """status model""" 51 | 52 | def __repr__(self): 53 | """repr""" 54 | return f"<{LIBRARY}.Status[{self.orderindex}] '{self.status}'>" 55 | 56 | 57 | class List(BaseModel): 58 | """List model""" 59 | 60 | def __init__(self, data: dict, **kwargs: Any) -> None: 61 | """override""" 62 | self.name = "" 63 | super().__init__(data, **kwargs) 64 | 65 | def __repr__(self): 66 | """repr""" 67 | return f"<{LIBRARY}.List[{self.id}] '{self.name}'>" 68 | 69 | def rename(self, new_name: str) -> Response: 70 | """renames a list""" 71 | if not self._client: 72 | raise MissingClient() 73 | rename_call = self._client.put(f"list/{self.id}", data={"name": new_name}) 74 | self.name = new_name 75 | return rename_call 76 | 77 | def get_tasks(self, **kwargs) -> ListType["Task"]: 78 | """gets tasks for the list""" 79 | if not self._client: 80 | raise MissingClient() 81 | return self._client._get_tasks( 82 | self.project.space.team.id, list_ids=[self.id], **kwargs # type: ignore 83 | ) 84 | 85 | def get_all_tasks(self, **kwargs) -> ListType["Task"]: 86 | """gets every task for this list""" 87 | if not self._client: 88 | raise MissingClient() 89 | return self._client._get_all_tasks( 90 | self.project.space.team.id, list_ids=[self.id], **kwargs # type: ignore 91 | ) 92 | 93 | def create_task( 94 | self, 95 | name: str, # string 96 | content: str = "", # optional, but nice 97 | assignees: ListType[Union[int, User]] = None, # list of User objects, or ints 98 | status: str = "Open", # needs to match your given statuses for the list 99 | priority: int = 0, # default to no priority (0). check Task class for enum 100 | due_date: Union[int, datetime] = None, # integer posix time, or python datetime 101 | ) -> Union[list, dict, Response]: 102 | """ 103 | creates a task within this list, returning the id of the task. 104 | 105 | unfortunately right now, there is no way to retreive a task by id 106 | this will return the ID of the newly created task, 107 | but you'll need to re-query the list for tasks to get the task object 108 | """ 109 | if not self._client: 110 | raise MissingClient() 111 | task_data = { 112 | "name": name, 113 | "content": content, 114 | "status": status, 115 | } # type: Dict[str, Any] 116 | 117 | if assignees: 118 | task_data["assignees"] = [ 119 | x if isinstance(x, int) else x.id for x in assignees 120 | ] 121 | 122 | if due_date: 123 | task_data["due_date"] = ( 124 | due_date if isinstance(due_date, int) else datetime_to_ts(due_date) 125 | ) 126 | 127 | if priority > 0: 128 | task_data["priority"] = priority 129 | 130 | new_task_call = self._client.post(f"list/{self.id}/task", data=task_data) 131 | return new_task_call["id"] 132 | 133 | 134 | class Project(BaseModel): 135 | """project model""" 136 | 137 | def __init__(self, data, **kwargs): 138 | """override to parse the members""" 139 | super().__init__(data, **kwargs) 140 | if self.override_statuses: 141 | self.statuses = ( 142 | [Status(x, client=self._client, project=self) for x in self.statuses] 143 | if self.statuses 144 | else [] 145 | ) 146 | else: 147 | self.statuses = [ 148 | Status(x, client=self._client, project=self) for x in DEFAULT_STATUSES 149 | ] 150 | self.lists = [List(x, client=self._client, project=self) for x in self.lists] 151 | 152 | def __repr__(self): 153 | """repr""" 154 | return f"<{LIBRARY}.Project[{self.id}] '{self.name}'>" 155 | 156 | def create_list(self, list_name: str) -> List: 157 | """creates a new list in this project: TODO get it updating""" 158 | if not self._client: 159 | raise MissingClient() 160 | new_list = self._client.post( 161 | f"project/{self.id}/list", data={"name": list_name} 162 | ) 163 | return new_list 164 | 165 | def get_list(self, list_id: str) -> List: 166 | """ 167 | gets a list by it's ID. 168 | Currently there is no get API call for this, so until API v2 is live, 169 | we have to do these this way 170 | """ 171 | return [x for x in self.lists if x.id == list_id][0] 172 | 173 | def get_tasks(self, **kwargs): 174 | """gets tasks for the project""" 175 | return self._client._get_tasks( 176 | self.space.team.id, project_ids=[self.id], **kwargs 177 | ) 178 | 179 | def get_all_tasks(self, **kwargs): 180 | """gets all of the tasks for the project""" 181 | return self._client._get_all_tasks( 182 | self.space.team.id, project_ids=[self.id], **kwargs 183 | ) 184 | 185 | 186 | class Space(BaseModel): 187 | """space model""" 188 | 189 | def __init__(self, data, **kwargs): 190 | """override to parse the members and statuses""" 191 | super().__init__(data, **kwargs) 192 | self.statuses = [ 193 | Status(x, client=self._client, space=self) for x in self.statuses 194 | ] 195 | self._projects = None 196 | 197 | def __repr__(self): 198 | """repr""" 199 | return f"<{LIBRARY}.Space[{self.id}] '{self.name}'>" 200 | 201 | @property 202 | def projects(self): 203 | """get the list of projects in the space""" 204 | if not self._projects or not self._client.cache: 205 | self._projects = [ 206 | Project(x, client=self._client, space=self) 207 | for x in self._client.get(f"space/{self.id}/project")["projects"] 208 | ] 209 | return self._projects 210 | 211 | def get_project(self, project_id: str) -> Project: 212 | """ 213 | gets a project by it's ID. 214 | Currently there is no get API call for this, so until API v2 is live, 215 | we have to do these this way 216 | """ 217 | return [x for x in self.projects if x.id == project_id][0] 218 | 219 | def get_tasks(self, **kwargs): 220 | """gets tasks for the space""" 221 | return self._client._get_tasks(self.team.id, space_ids=[self.id], **kwargs) 222 | 223 | def get_all_tasks(self, **kwargs): 224 | """gets all the tasks for the space""" 225 | return self._client._get_all_tasks(self.team.id, space_ids=[self.id], **kwargs) 226 | 227 | 228 | class Team(BaseModel): 229 | """team object""" 230 | 231 | def __init__(self, data, **kwargs): 232 | """override to parse the members""" 233 | super().__init__(data, **kwargs) 234 | self.members = [User(x, client=self._client, team=self) for x in self.members] 235 | self._spaces = None 236 | 237 | def __repr__(self): 238 | """repr""" 239 | return f"<{LIBRARY}.Team[{self.id}] '{self.name}'>" 240 | 241 | @property 242 | def spaces(self): 243 | """gets a list of all the spaces in this team""" 244 | if not self._spaces or not self._client.cache: 245 | self._spaces = [ 246 | Space(x, client=self._client, team=self) 247 | for x in self._client.get(f"team/{self.id}/space")["spaces"] 248 | ] 249 | return self._spaces 250 | 251 | def get_space(self, space_id: str) -> Space: 252 | """ 253 | gets a space by it's ID. 254 | Currently there is no get API call for this, so until API v2 is live, 255 | we have to do these this way 256 | """ 257 | return [x for x in self.spaces if x.id == space_id][0] 258 | 259 | def get_tasks(self, **kwargs): 260 | """gets tasks for the team""" 261 | return self._client._get_tasks(self.id, **kwargs) 262 | 263 | def get_all_tasks(self, **kwargs): 264 | """gets all of the tasks for the team""" 265 | return self._client._get_all_tasks(self.id, **kwargs) 266 | 267 | 268 | class Tag(BaseModel): 269 | """Tag object""" 270 | 271 | def __repr__(self): 272 | """repr""" 273 | return f"<{LIBRARY}.Tag '{self.name}'>" 274 | 275 | 276 | class Task(BaseModel): 277 | """Task object""" 278 | 279 | class Priority: 280 | """task priority enum""" 281 | 282 | NONE = 0 283 | URGENT = 1 284 | HIGH = 2 285 | NORMAL = 3 286 | LOW = 4 287 | 288 | def __init__(self, data, **kwargs): 289 | """override to parse the data""" 290 | super().__init__(data, **kwargs) 291 | self.creator = User(self.creator, client=self._client) 292 | self.status = Status(self.status, client=self._client) 293 | self.tags = [Tag(x) for x in self.tags] 294 | self.assignees = [User(x, client=self._client) for x in self.assignees] 295 | self.due_date = ts_to_datetime(self.due_date) if self.due_date else None 296 | self.start_date = ts_to_datetime(self.start_date) if self.start_date else None 297 | self.date_created = ( 298 | ts_to_datetime(self.date_created) if self.date_created else None 299 | ) 300 | self.date_updated = ( 301 | ts_to_datetime(self.date_updated) if self.date_updated else None 302 | ) 303 | self.date_closed = ( 304 | ts_to_datetime(self.date_closed) if self.date_closed else None 305 | ) 306 | 307 | def __repr__(self): 308 | """repr""" 309 | return f"<{LIBRARY}.Task[{self.id}] '{self.name}'>" 310 | 311 | def update( 312 | self, 313 | name: str = None, # string 314 | content: str = None, # string 315 | add_assignees: ListType[Union[int, User]] = None, 316 | remove_assignees: ListType[Union[int, User]] = None, 317 | status: str = None, # string 318 | priority: int = None, # integer 319 | due_date: Union[int, datetime] = None, # integer posix time, or python datetime 320 | ) -> Union[list, dict, Response]: 321 | """updates the task""" 322 | if not self._client: 323 | raise MissingClient() 324 | if not add_assignees: 325 | add_assignees = [] 326 | if not remove_assignees: 327 | remove_assignees = [] 328 | path = f"task/{self.id}" 329 | data = { 330 | "assignees": { 331 | "add": [x if isinstance(x, int) else x.id for x in add_assignees], 332 | "rem": [x if isinstance(x, int) else x.id for x in remove_assignees], 333 | } 334 | } # type: Dict[str, Any] 335 | 336 | if name: 337 | data["name"] = name 338 | if content: 339 | data["content"] = content 340 | if status: 341 | data["status"] = status 342 | if priority: 343 | data["priority"] = priority 344 | if due_date: 345 | data["due_date"] = ( 346 | due_date if isinstance(due_date, int) else datetime_to_ts(due_date) 347 | ) 348 | 349 | return self._client.put(path, data=data) 350 | --------------------------------------------------------------------------------