├── .all-contributorsrc ├── .config.mk ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── Jenkinsfile ├── LICENSE ├── Makefile ├── README.md ├── logdna ├── VERSION ├── __init__.py ├── configs.py ├── logdna.py └── utils.py ├── poetry.lock ├── pyproject.toml ├── scripts └── json_coverage.py ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── test_logger.py └── test_utils.py /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "python", 3 | "projectOwner": "logdna", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 100, 10 | "commit": true, 11 | "commitConvention": "angular", 12 | "contributors": [ 13 | { 14 | "login": "respectus", 15 | "name": "Muaz Siddiqui", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/1046364?v=4", 17 | "profile": "https://github.com/respectus", 18 | "contributions": [ 19 | "code", 20 | "doc", 21 | "test" 22 | ] 23 | }, 24 | { 25 | "login": "smusali", 26 | "name": "Samir Musali", 27 | "avatar_url": "https://avatars.githubusercontent.com/u/34287490?v=4", 28 | "profile": "https://github.com/smusali", 29 | "contributions": [ 30 | "code", 31 | "doc", 32 | "test" 33 | ] 34 | }, 35 | { 36 | "login": "vilyapilya", 37 | "name": "vilyapilya", 38 | "avatar_url": "https://avatars.githubusercontent.com/u/17367511?v=4", 39 | "profile": "https://github.com/vilyapilya", 40 | "contributions": [ 41 | "code", 42 | "maintenance", 43 | "test" 44 | ] 45 | }, 46 | { 47 | "login": "mikehu", 48 | "name": "Mike Hu", 49 | "avatar_url": "https://avatars.githubusercontent.com/u/981800?v=4", 50 | "profile": "https://github.com/mikehu", 51 | "contributions": [ 52 | "doc" 53 | ] 54 | }, 55 | { 56 | "login": "esatterwhite", 57 | "name": "Eric Satterwhite", 58 | "avatar_url": "https://avatars.githubusercontent.com/u/148561?v=4", 59 | "profile": "http://codedependant.net/", 60 | "contributions": [ 61 | "code", 62 | "doc", 63 | "test", 64 | "tool" 65 | ] 66 | }, 67 | { 68 | "login": "utek", 69 | "name": "Łukasz Bołdys (Lukasz Boldys)", 70 | "avatar_url": "https://avatars.githubusercontent.com/u/128036?v=4", 71 | "profile": "http://dev.utek.pl/", 72 | "contributions": [ 73 | "code", 74 | "bug" 75 | ] 76 | }, 77 | { 78 | "login": "baronomasia", 79 | "name": "Ryan", 80 | "avatar_url": "https://avatars.githubusercontent.com/u/4133158?v=4", 81 | "profile": "https://github.com/baronomasia", 82 | "contributions": [ 83 | "doc" 84 | ] 85 | }, 86 | { 87 | "login": "LYHuang", 88 | "name": "Mike Huang", 89 | "avatar_url": "https://avatars.githubusercontent.com/u/14082239?v=4", 90 | "profile": "https://github.com/LYHuang", 91 | "contributions": [ 92 | "code", 93 | "bug" 94 | ] 95 | }, 96 | { 97 | "login": "danmaas", 98 | "name": "Dan Maas", 99 | "avatar_url": "https://avatars.githubusercontent.com/u/9013104?v=4", 100 | "profile": "https://www.medium.com/@dmaas", 101 | "contributions": [ 102 | "code" 103 | ] 104 | }, 105 | { 106 | "login": "dchai76", 107 | "name": "DChai", 108 | "avatar_url": "https://avatars.githubusercontent.com/u/13873467?v=4", 109 | "profile": "https://github.com/dchai76", 110 | "contributions": [ 111 | "doc" 112 | ] 113 | }, 114 | { 115 | "login": "jdemaeyer", 116 | "name": "Jakob de Maeyer ", 117 | "avatar_url": "https://avatars.githubusercontent.com/u/10531844?v=4", 118 | "profile": "https://naboa.de/", 119 | "contributions": [ 120 | "code" 121 | ] 122 | }, 123 | { 124 | "login": "sataloger", 125 | "name": "Andrey Babak", 126 | "avatar_url": "https://avatars.githubusercontent.com/u/359111?v=4", 127 | "profile": "https://github.com/sataloger", 128 | "contributions": [ 129 | "code" 130 | ] 131 | }, 132 | { 133 | "login": "SpainTrain", 134 | "name": "Mike S", 135 | "avatar_url": "https://avatars.githubusercontent.com/u/380032?v=4", 136 | "profile": "https://github.com/mike-spainhower", 137 | "contributions": [ 138 | "code", 139 | "doc" 140 | ] 141 | }, 142 | { 143 | "login": "btashton", 144 | "name": "Brennan Ashton", 145 | "avatar_url": "https://avatars.githubusercontent.com/u/173245?v=4", 146 | "profile": "https://github.com/btashton", 147 | "contributions": [ 148 | "code" 149 | ] 150 | }, 151 | { 152 | "login": "inkrement", 153 | "name": "Christian Hotz-Behofsits", 154 | "avatar_url": "https://avatars.githubusercontent.com/u/604528?v=4", 155 | "profile": "http://justrocketscience.com/", 156 | "contributions": [ 157 | "code", 158 | "bug" 159 | ] 160 | }, 161 | { 162 | "login": "kurtiss", 163 | "name": "Kurtiss Hare", 164 | "avatar_url": "https://avatars.githubusercontent.com/u/108118?v=4", 165 | "profile": "http://www.kinoarts.com/blog/", 166 | "contributions": [ 167 | "bug" 168 | ] 169 | }, 170 | { 171 | "login": "rudyryk", 172 | "name": "Alexey Kinev", 173 | "avatar_url": "https://avatars.githubusercontent.com/u/4500?v=4", 174 | "profile": "https://twitter.com/rudyryk", 175 | "contributions": [ 176 | "bug" 177 | ] 178 | }, 179 | { 180 | "login": "matthiasfru", 181 | "name": "matthiasfru", 182 | "avatar_url": "https://avatars.githubusercontent.com/u/24245643?v=4", 183 | "profile": "https://github.com/matthiasfru", 184 | "contributions": [ 185 | "bug" 186 | ] 187 | } 188 | ], 189 | "contributorsPerLine": 7 190 | } 191 | -------------------------------------------------------------------------------- /.config.mk: -------------------------------------------------------------------------------- 1 | # Below is an example of pulling the current version of a node app. 2 | 3 | #VERSION is being deprecated by APP_VERSION - no changes necessary - see Makefile 4 | #APP_VERSION=$(shell awk '/version/ {gsub(/[",]/,""); print $$2}' package.json) 5 | 6 | GIT_AUTHOR_NAME ?= $(shell git config --get user.name) 7 | GIT_AUTHOR_EMAIL ?= $(shell git config --get user.email) 8 | GIT_COMMITTER_NAME ?= $(GIT_AUTHOR_NAME) 9 | GIT_COMMITTER_EMAIL ?= $(GIT_AUTHOR_EMAIL) 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | **/*cache* 3 | build/ 4 | develop-eggs 5 | dist 6 | src/*.egg-info 7 | logdna.egg-info/ 8 | LogDNAHandler.log 9 | bin 10 | .installed.cfg 11 | coverage/ 12 | .coverage 13 | Session.vim 14 | .cache 15 | pypoetry/ 16 | pip/ 17 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | 4 | 5 | ## v1.18.12 (2023-07-27) 6 | ### Fix 7 | * Don't overwrite base logging Handler class lock var ([#108](https://github.com/logdna/python/issues/108)) ([`5b04d72`](https://github.com/logdna/python/commit/5b04d72b42686d926fb5e73dadb2d7a1ba16d9c3)) 8 | 9 | ## v1.18.11 (2023-07-26) 10 | ### Fix 11 | * Remove Thread/event to fix Django regression ([#107](https://github.com/logdna/python/issues/107)) ([`0337a09`](https://github.com/logdna/python/commit/0337a09433953227dabb0b65da8583e8f9273986)) 12 | 13 | ## v1.18.10 (2023-07-26) 14 | ### Fix 15 | * Utilize apikey header for auth to make compatible with pipelines ([#104](https://github.com/logdna/python/issues/104)) ([`5394e97`](https://github.com/logdna/python/commit/5394e9714779878cd415a4566cd44d9183e150b9)) 16 | 17 | ## v1.18.9 (2023-07-21) 18 | ### Fix 19 | * Make flush thread a daemon thread to prevent shutdown hang ([#102](https://github.com/logdna/python/issues/102)) ([`17a69b0`](https://github.com/logdna/python/commit/17a69b044de43a7a9d7d3e6eb65a0c60f1fa23f0)) 20 | 21 | ## v1.18.8 (2023-07-18) 22 | ### Fix 23 | * Bump semver ([#101](https://github.com/logdna/python/issues/101)) ([`913e5f4`](https://github.com/logdna/python/commit/913e5f4f35f2920a6b2162022b93495b4774654c)) 24 | 25 | ## v1.18.7 (2023-05-05) 26 | ### Fix 27 | * Gate buils from non maintainers ([`97803b5`](https://github.com/logdna/python/commit/97803b55a102539a75b0763891c1d27757460067)) 28 | 29 | ## v1.18.6 (2023-01-27) 30 | ### Fix 31 | * Added retries for http 429 504 ([#93](https://github.com/logdna/python/issues/93)) ([`712d81d`](https://github.com/logdna/python/commit/712d81d4ab2bfbf95d65898fb365a5bcb1396199)) 32 | 33 | ## v1.18.5 (2023-01-27) 34 | ### Fix 35 | * **chore:** Upgraded pytest to resolve security vulnerability in py <= 1.11.0 ([#92](https://github.com/logdna/python/issues/92)) ([`905b06a`](https://github.com/logdna/python/commit/905b06a648e19896087b0c51ba1e055212727560)) 36 | 37 | ## v1.18.4 (2023-01-06) 38 | ### Fix 39 | * Dependabot -> Vulnerabilities -> cryptography >= 37.0.0 < 38.0.3 ([#91](https://github.com/logdna/python/issues/91)) ([`7ccde50`](https://github.com/logdna/python/commit/7ccde50ba50c0abbec1a7efe4dd665e8b35511c0)) 40 | 41 | ## v1.18.3 (2022-12-07) 42 | ### Fix 43 | * Add documentation for log_error_response option ([#88](https://github.com/logdna/python/issues/88)) ([`d5bf85c`](https://github.com/logdna/python/commit/d5bf85ca26579e0186e1abdbcd1b6c44c60c9eca)) 44 | 45 | ## v1.18.2 (2022-05-10) 46 | ### Fix 47 | * Requests error handling ([#82](https://github.com/logdna/python/issues/82)) ([`e412859`](https://github.com/logdna/python/commit/e4128592aa9c2301c6467115148f2f23f88da9d3)) 48 | 49 | ## v1.18.1 (2021-11-18) 50 | ### Fix 51 | * **threading:** Account for secondary buffer flush and deadlock ([`d00b952`](https://github.com/logdna/python/commit/d00b9529d116ddf9ba454462d4d804dc54423c83)) 52 | 53 | ## v1.18.0 (2021-07-26) 54 | ### Fix 55 | * **opts:** Repair logging options for each call ([`e326e4c`](https://github.com/logdna/python/commit/e326e4c2461b808b5d3a885b37555f8e610615e4)) 56 | 57 | ## v1.17.0 (2021-07-15) 58 | ### Feature 59 | * **meta:** Enable adding custom meta fields ([`f250237`](https://github.com/logdna/python/commit/f250237dbde932e99ab199023516f66a248c5e80)) 60 | * **threadWorkerPools:** Introduce extra threads ([`9bfe479`](https://github.com/logdna/python/commit/9bfe479132acb0aa8e5784f2aa31298606e49789)) 61 | 62 | ### Documentation 63 | * Update the README as requested ([`5dd754f`](https://github.com/logdna/python/commit/5dd754f177675eb25cd5d5449bd3bcc8286f8739)) 64 | 65 | ## v1.16.0 (2021-04-15) 66 | 67 | 68 | ## v1.15.0 (2021-04-15) 69 | 70 | 71 | ## v1.14.0 (2021-04-15) 72 | 73 | 74 | ## v1.13.0 (2021-04-15) 75 | 76 | 77 | ## v1.12.0 (2021-04-15) 78 | 79 | 80 | ## v1.11.0 (2021-04-15) 81 | 82 | 83 | ## v1.10.0 (2021-04-15) 84 | 85 | 86 | ## v1.9.0 (2021-04-15) 87 | 88 | 89 | ## v1.8.0 (2021-04-15) 90 | 91 | 92 | ## v1.7.0 (2021-04-07) 93 | 94 | 95 | ## v1.6.0 (2021-04-07) 96 | ### Feature 97 | * **ci:** Enable releases through semantic release ([`9e4b1c0`](https://github.com/logdna/python/commit/9e4b1c0a43bc0941ba4fb336ea12a3f497622ee6)) 98 | * **ci:** Include jenkins setup ([`1c5be37`](https://github.com/logdna/python/commit/1c5be37ef32776f2d0ba2b68b67cebb58b1f0177)) 99 | * **tasks:** Convert run scripts to tasks ([`f5a8b18`](https://github.com/logdna/python/commit/f5a8b182941a0c514594cbaa7a3ac5952173d5a8)) 100 | * **lint:** Setup linting + test harness ([`0a7763a`](https://github.com/logdna/python/commit/0a7763a2befbf598b59d7ab595b19e22b173fda5)) 101 | * **tags:** Allow tags to be configured ([`6adc21e`](https://github.com/logdna/python/commit/6adc21e872fa521be1aaf08309f7f3d0ba3dc5c5)) 102 | * **meta:** Python log info in meta object ([`cf9b505`](https://github.com/logdna/python/commit/cf9b505734df12918a665a8a8c74d4fd74e5bc47)) 103 | * **handlers:** Make available via config file ([`39c5dec`](https://github.com/logdna/python/commit/39c5decd98e8d4feb6c1bbfa487faf35396c8b12)) 104 | 105 | ### Fix 106 | * **ci:** Correct invalid environment variable ([`fce4d59`](https://github.com/logdna/python/commit/fce4d5995b31c426f2b66992b57f129f22f9f18f)) 107 | 108 | ### Documentation 109 | * Add @matthiasfru as a contributor ([`3727bc3`](https://github.com/logdna/python/commit/3727bc3386d3dd6465e0b0d15675a091a3743c24)) 110 | * Add @rudyryk as a contributor ([`9ab0e39`](https://github.com/logdna/python/commit/9ab0e3932180c41bff1cd0944c24fb8e208f4391)) 111 | * Add @kurtiss as a contributor ([`1476085`](https://github.com/logdna/python/commit/14760857649b56207240bae907386a33f7f1666b)) 112 | * Update @inkrement as a contributor ([`6a2cede`](https://github.com/logdna/python/commit/6a2cedef6695e18a981a3605176c9cffdd159827)) 113 | * Add @inkrement as a contributor ([`7ba6992`](https://github.com/logdna/python/commit/7ba6992287f98c478da72f5982a0869b5d835df7)) 114 | * Add @btashton as a contributor ([`c1e69bf`](https://github.com/logdna/python/commit/c1e69bfc965e1e1e5717ec7af3271dc0bf6b502d)) 115 | * Add @SpainTrain as a contributor ([`7b9f04b`](https://github.com/logdna/python/commit/7b9f04b86a1e813bccea29ea8db1554808ea48b6)) 116 | * Add @sataloger as a contributor ([`04081f0`](https://github.com/logdna/python/commit/04081f039d6136a66b259f7245ad7845ef1c080a)) 117 | * Add @jdemaeyer as a contributor ([`69e8f7c`](https://github.com/logdna/python/commit/69e8f7cc0782a778d0da230ff1a8feb5f8cee352)) 118 | * Add @dchai76 as a contributor ([`42e9c6b`](https://github.com/logdna/python/commit/42e9c6bddc2bce29714072bba37d85d9a734eac2)) 119 | * Add @danmaas as a contributor ([`23bbab4`](https://github.com/logdna/python/commit/23bbab48cdef4aaef6813459dbdb23dbd9c60374)) 120 | * Add @LYHuang as a contributor ([`3cc9e23`](https://github.com/logdna/python/commit/3cc9e232d0b12b9f0315efb1d618426b486a2604)) 121 | * Add @baronomasia as a contributor ([`8971bd7`](https://github.com/logdna/python/commit/8971bd713d74cd9386c60a50cc75a7fc3591f544)) 122 | * Add @utek as a contributor ([`ea495b4`](https://github.com/logdna/python/commit/ea495b479cc0549ff68a1e816c3f7a174c1c138c)) 123 | * Add @esatterwhite as a contributor ([`0a334bd`](https://github.com/logdna/python/commit/0a334bdb5690049634c64eb0f9c6c1026b9ab001)) 124 | * Add @mikehu as a contributor ([`e7aa7cb`](https://github.com/logdna/python/commit/e7aa7cb2624313c065f7bbc8a129c1a4841f9ec2)) 125 | * Add @vilyapilya as a contributor ([`e3191f5`](https://github.com/logdna/python/commit/e3191f577fb7ddf7590ca1e1bf239f66d2f30fd0)) 126 | * Add @smusali as a contributor ([`4d5022f`](https://github.com/logdna/python/commit/4d5022f93948cca239ebc104e34034c350956f65)) 127 | * Add @respectus as a contributor ([`85a543c`](https://github.com/logdna/python/commit/85a543c9dc27a3c6064e790be0ba1c475187c38d)) 128 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor's Code of Conduct 2 | 3 | If you contribute to this repo, you agree to abide by this code of conduct for 4 | this community. 5 | 6 | We abide by the 7 | [Contributor Covenant Code of Conduct, 2.0](https://www.contributor-covenant.org/version/2/0/code_of_conduct/). 8 | It is reproduced below for ease of use. 9 | 10 | # Contributor Covenant Code of Conduct 11 | 12 | ## Our Pledge 13 | 14 | We as members, contributors, and leaders pledge to make participation in our 15 | community a harassment-free experience for everyone, regardless of age, body 16 | size, visible or invisible disability, ethnicity, sex characteristics, gender 17 | identity and expression, level of experience, education, socio-economic status, 18 | nationality, personal appearance, race, religion, or sexual identity 19 | and orientation. 20 | 21 | We pledge to act and interact in ways that contribute to an open, welcoming, 22 | diverse, inclusive, and healthy community. 23 | 24 | ## Our Standards 25 | 26 | Examples of behavior that contributes to a positive environment for our 27 | community include: 28 | 29 | * Demonstrating empathy and kindness toward other people 30 | * Being respectful of differing opinions, viewpoints, and experiences 31 | * Giving and gracefully accepting constructive feedback 32 | * Accepting responsibility and apologizing to those affected by our mistakes, 33 | and learning from the experience 34 | * Focusing on what is best not just for us as individuals, but for the 35 | overall community 36 | 37 | Examples of unacceptable behavior include: 38 | 39 | * The use of sexualized language or imagery, and sexual attention or 40 | advances of any kind 41 | * Trolling, insulting or derogatory comments, and personal or political attacks 42 | * Public or private harassment 43 | * Publishing others' private information, such as a physical or email 44 | address, without their explicit permission 45 | * Other conduct which could reasonably be considered inappropriate in a 46 | professional setting 47 | 48 | ## Enforcement Responsibilities 49 | 50 | Community leaders are responsible for clarifying and enforcing our standards of 51 | acceptable behavior and will take appropriate and fair corrective action in 52 | response to any behavior that they deem inappropriate, threatening, offensive, 53 | or harmful. 54 | 55 | Community leaders have the right and responsibility to remove, edit, or reject 56 | comments, commits, code, wiki edits, issues, and other contributions that are 57 | not aligned to this Code of Conduct, and will communicate reasons for moderation 58 | decisions when appropriate. 59 | 60 | ## Scope 61 | 62 | This Code of Conduct applies within all community spaces, and also applies when 63 | an individual is officially representing the community in public spaces. 64 | Examples of representing our community include using an official e-mail address, 65 | posting via an official social media account, or acting as an appointed 66 | representative at an online or offline event. 67 | 68 | ## Enforcement 69 | 70 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 71 | reported to the community leaders responsible for enforcement at 72 | [opensource@logdna.com](mailto:opensource@logdna.com). 73 | All complaints will be reviewed and investigated promptly and fairly. 74 | 75 | All community leaders are obligated to respect the privacy and security of the 76 | reporter of any incident. 77 | 78 | ## Enforcement Guidelines 79 | 80 | Community leaders will follow these Community Impact Guidelines in determining 81 | the consequences for any action they deem in violation of this Code of Conduct: 82 | 83 | ### 1. Correction 84 | 85 | **Community Impact**: Use of inappropriate language or other behavior deemed 86 | unprofessional or unwelcome in the community. 87 | 88 | **Consequence**: A private, written warning from community leaders, providing 89 | clarity around the nature of the violation and an explanation of why the 90 | behavior was inappropriate. A public apology may be requested. 91 | 92 | ### 2. Warning 93 | 94 | **Community Impact**: A violation through a single incident or series 95 | of actions. 96 | 97 | **Consequence**: A warning with consequences for continued behavior. No 98 | interaction with the people involved, including unsolicited interaction with 99 | those enforcing the Code of Conduct, for a specified period of time. This 100 | includes avoiding interactions in community spaces as well as external channels 101 | like social media. Violating these terms may lead to a temporary or 102 | permanent ban. 103 | 104 | ### 3. Temporary Ban 105 | 106 | **Community Impact**: A serious violation of community standards, including 107 | sustained inappropriate behavior. 108 | 109 | **Consequence**: A temporary ban from any sort of interaction or public 110 | communication with the community for a specified period of time. No public or 111 | private interaction with the people involved, including unsolicited interaction 112 | with those enforcing the Code of Conduct, is allowed during this period. 113 | Violating these terms may lead to a permanent ban. 114 | 115 | ### 4. Permanent Ban 116 | 117 | **Community Impact**: Demonstrating a pattern of violation of community 118 | standards, including sustained inappropriate behavior, harassment of an 119 | individual, or aggression toward or disparagement of classes of individuals. 120 | 121 | **Consequence**: A permanent ban from any sort of public interaction within 122 | the community. 123 | 124 | ## Attribution 125 | 126 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 127 | version 2.0, available at 128 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 129 | 130 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 131 | enforcement ladder](https://github.com/mozilla/diversity). 132 | 133 | [homepage]: https://www.contributor-covenant.org 134 | 135 | For answers to common questions about this code of conduct, see the FAQ at 136 | https://www.contributor-covenant.org/faq. Translations are available at 137 | https://www.contributor-covenant.org/translations. 138 | 139 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Process 4 | 5 | We use a fork-and-PR process, also known as a triangular workflow. This process 6 | is fairly common in open-source projects. Here's the basic workflow: 7 | 8 | 1. Fork the upstream repo to create your own repo. This repo is called the origin repo. 9 | 2. Clone the origin repo to create a working directory on your local machine. 10 | 3. Work your changes on a branch in your working directory, then add, commit, and push your work to your origin repo. 11 | 4. Submit your changes as a PR against the upstream repo. You can use the upstream repo UI to do this. 12 | 5. Maintainers review your changes. If they ask for changes, you work on your 13 | origin repo's branch and then submit another PR. Otherwise, if no changes are made, 14 | then the branch with your PR is merged to upstream's main trunk, the master branch. 15 | 16 | When you work in a triangular workflow, you have the upstream repo, the origin 17 | repo, and then your working directory (the clone of the origin repo). You do 18 | a `git fetch` from upstream to local, push from local to origin, and then do a PR from origin to 19 | upstream—a triangle. 20 | 21 | If this workflow is too much to understand to start, that's ok! You can use 22 | GitHub's UI to make a change, which is autoset to do most of this process for 23 | you. We just want you to be aware of how the entire process works before 24 | proposing a change. 25 | 26 | Thank you for your contributions; we appreciate you! 27 | 28 | ## License 29 | 30 | Note that we use a standard [MIT](./LICENSE) license on this repo. 31 | 32 | ## Coding style 33 | 34 | Currently the project is auto formatted following the [PEP8][] 35 | style guide 36 | 37 | [PEP8]: https://www.python.org/dev/peps/pep-0008 38 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM condaforge/miniforge3:4.12.0-0 2 | 3 | RUN conda install -y gcc pip poetry=1.1.7 git 4 | RUN mkdir /workdir && chmod 777 /workdir 5 | RUN git config --global --add safe.directory /workdir 6 | WORKDIR /workdir 7 | 8 | -------------------------------------------------------------------------------- /Jenkinsfile: -------------------------------------------------------------------------------- 1 | library 'magic-butler-catalogue' 2 | def PROJECT_NAME = 'logdna-python' 3 | def TRIGGER_PATTERN = ".*@logdnabot.*" 4 | def DEFAULT_BRANCH = 'master' 5 | def CURRENT_BRANCH = [env.CHANGE_BRANCH, env.BRANCH_NAME]?.find{branch -> branch != null} 6 | 7 | pipeline { 8 | agent { 9 | node { 10 | label 'ec2-fleet' 11 | customWorkspace "${PROJECT_NAME}-${BUILD_NUMBER}" 12 | } 13 | } 14 | 15 | options { 16 | timestamps() 17 | ansiColor 'xterm' 18 | } 19 | 20 | triggers { 21 | issueCommentTrigger(TRIGGER_PATTERN) 22 | } 23 | 24 | stages { 25 | stage('Validate PR Source') { 26 | when { 27 | expression { env.CHANGE_FORK } 28 | not { 29 | triggeredBy 'issueCommentCause' 30 | } 31 | } 32 | steps { 33 | error("A maintainer needs to approve this PR for CI by commenting") 34 | } 35 | } 36 | stage('Test') { 37 | 38 | steps { 39 | sh 'make install lint test' 40 | } 41 | 42 | post { 43 | always { 44 | junit 'coverage/test.xml' 45 | publishHTML target: [ 46 | allowMissing: false, 47 | alwaysLinkToLastBuild: false, 48 | keepAll: true, 49 | reportDir: 'coverage', 50 | reportFiles: 'index.html', 51 | reportName: "coverage-${BUILD_TAG}" 52 | ] 53 | } 54 | } 55 | } 56 | 57 | stage('Release') { 58 | 59 | stages { 60 | stage('dry run') { 61 | when { 62 | not { 63 | branch "${DEFAULT_BRANCH}" 64 | } 65 | } 66 | 67 | environment { 68 | GH_TOKEN = credentials('github-api-token') 69 | PYPI_TOKEN = credentials('pypi-token') 70 | JENKINS_URL = "${JENKINS_URL}" 71 | BRANCH_NAME = "${DEFAULT_BRANCH}" 72 | GIT_BRANCH = "${DEFAULT_BRANCH}" 73 | CHANGE_ID = '' 74 | } 75 | 76 | steps { 77 | sh "make release-dry" 78 | } 79 | } 80 | 81 | stage('publish') { 82 | 83 | environment { 84 | GH_TOKEN = credentials('github-api-token') 85 | PYPI_TOKEN = credentials('pypi-token') 86 | JENKINS_URL = "${JENKINS_URL}" 87 | } 88 | 89 | when { 90 | branch "${DEFAULT_BRANCH}" 91 | not { 92 | changelog '\\[skip ci\\]' 93 | } 94 | } 95 | steps { 96 | sh 'make release' 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 LogDNA 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 | # Makefile Version 2021032403 2 | # 3 | # Source in repository specific environment variables 4 | include .config.mk 5 | 6 | # Define commands via docker 7 | DOCKER = docker 8 | DOCKER_RUN := $(DOCKER) run --rm -i 9 | WORKDIR :=/workdir 10 | DOCKER_COMMAND := $(DOCKER_RUN) -u "$(shell id -u)":"$(shell id -g)" -v $(PWD):$(WORKDIR):Z -w $(WORKDIR) \ 11 | -e XDG_CONFIG_HOME=$(WORKDIR) \ 12 | -e XDG_CACHE_HOME=$(WORKDIR) \ 13 | -e POETRY_CACHE_DIR=$(WORKDIR)/.cache \ 14 | -e POETRY_VIRTUALENV_IN_PROJECT=true \ 15 | -e PYPI_TOKEN \ 16 | -e GH_TOKEN \ 17 | -e JENKINS_URL \ 18 | -e BRANCH_NAME \ 19 | -e CHANGE_ID \ 20 | -e GIT_AUTHOR_NAME \ 21 | -e GIT_AUTHOR_EMAIL \ 22 | -e GIT_COMMITTER_NAME \ 23 | -e GIT_COMMITTER_EMAIL \ 24 | logdna-poetry:local 25 | 26 | 27 | POETRY_COMMAND := $(DOCKER_COMMAND) poetry 28 | 29 | # Exports the variables for shell use 30 | export 31 | 32 | # build image 33 | .PHONY:build-image 34 | build-image: 35 | DOCKER_BUILDKIT=1 $(DOCKER) build -t logdna-poetry:local . 36 | 37 | # This helper function makes debugging much easier. 38 | .PHONY:debug-% 39 | debug-%: ## Debug a variable by calling `make debug-VARIABLE` 40 | @echo $(*) = $($(*)) 41 | 42 | .PHONY:help 43 | .SILENT:help 44 | help: ## Show this help, includes list of all actions. 45 | @awk 'BEGIN {FS = ":.*?## "}; /^.+: .*?## / && !/awk/ {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' ${MAKEFILE_LIST} 46 | 47 | .PHONY:run 48 | run: install ## purge build time artifacts 49 | $(DOCKER_COMMAND) bash 50 | 51 | .PHONY:clean 52 | clean: ## purge build time artifacts 53 | rm -rf dist/ build/ coverage/ pypoetry/ pip/ **/__pycache__/ .pytest_cache/ .cache .coverage 54 | 55 | .PHONY:changelog 56 | changelog: install ## print the next version of the change log to stdout 57 | $(POETRY_COMMAND) run semantic-release changelog --unreleased 58 | 59 | .PHONY:install 60 | install: build-image ## install development and build time dependencies 61 | $(POETRY_COMMAND) install --no-interaction 62 | 63 | .PHONY:lint 64 | lint: install ## run lint rules and print error report 65 | $(POETRY_COMMAND) run task lint 66 | 67 | .PHONY:lint-fix 68 | lint-fix: install ## attempt to auto fix linting error and report remaining errors 69 | $(POETRY_COMMAND) run task lint:fix 70 | 71 | .PHONY:package 72 | package: install ## Generate a python sdist and wheel 73 | $(POETRY_COMMAND) build 74 | 75 | .PHONY:release 76 | release: clean install fetch-tags ## run semantic release build and publish results to github + pypi based on unreleased commits 77 | $(POETRY_COMMAND) run task release 78 | 79 | .PHONY: fetch-tags 80 | fetch-tags: ## workaround for jenkins repo cloning behavior 81 | git fetch origin --tags 82 | 83 | .PHONY:release-dry 84 | release-dry: clean install fetch-tags changelog ## run semantic release in noop mode 85 | $(POETRY_COMMAND) run semantic-release publish --noop --verbosity=DEBUG 86 | 87 | .PHONY:release-patch 88 | release-patch: clean install ## run semantic release build and force a patch release 89 | $(POETRY_COMMAND) run semantic-release publish --patch 90 | 91 | .PHONY:release-minor 92 | release-minor: clean install ## run semantic release build and force a minor release 93 | $(POETRY_COMMAND) run semantic-release publish --minor 94 | 95 | .PHONY:release-major 96 | release-major: clean install ## run semantic release build and force a major release 97 | $(POETRY_COMMAND) run semantic-release publish --major 98 | 99 | .PHONY:test 100 | test: install ## run project test suite 101 | $(POETRY_COMMAND) run task test 102 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |

Python package for logging to LogDNA

6 |

7 | 8 | 9 | [![All Contributors](https://img.shields.io/badge/all_contributors-18-orange.svg?style=flat-square)](#contributors-) 10 | 11 | 12 | --- 13 | 14 | * [Installation](#installation) 15 | * [Setup](#setup) 16 | * [Usage](#usage) 17 | * [Usage with fileConfig](#usage-with-fileconfig) 18 | * [API](#api) 19 | * [LogDNAHandler(key: string, [options: dict])](#logdnahandlerkey-string-options-dict) 20 | * [key](#key) 21 | * [options](#options) 22 | * [log(line, [options])](#logline-options) 23 | * [line](#line) 24 | * [options](#options-1) 25 | * [Development](#development) 26 | * [Scripts](#scripts) 27 | * [License](#license) 28 | 29 | ## Installation 30 | 31 | ```bash 32 | $ pip install logdna 33 | ``` 34 | 35 | ## Setup 36 | ```python 37 | import logging 38 | from logdna import LogDNAHandler 39 | import os 40 | 41 | # Set your key as an env variable 42 | # then import here, its best not to 43 | # hard code your key! 44 | key=os.environ['INGESTION_KEY'] 45 | 46 | log = logging.getLogger('logdna') 47 | log.setLevel(logging.INFO) 48 | 49 | options = { 50 | 'hostname': 'pytest', 51 | 'ip': '10.0.1.1', 52 | 'mac': 'C0:FF:EE:C0:FF:EE' 53 | } 54 | 55 | # Defaults to False; when True meta objects are searchable 56 | options['index_meta'] = True 57 | options['custom_fields'] = 'meta' 58 | 59 | 60 | test = LogDNAHandler(key, options) 61 | 62 | log.addHandler(test) 63 | 64 | log.warning("Warning message", extra={'app': 'bloop'}) 65 | log.info("Info message") 66 | 67 | ``` 68 | _**Required**_ 69 | * [LogDNA Ingestion Key](https://app.logdna.com/manage/profile) 70 | 71 | _**Optional**_ 72 | * Hostname - ([string][]) 73 | * MAC Address - ([string][]) 74 | * IP Address - ([string][]) 75 | * Index Meta - ([bool][]) - formatted as `options['index_meta']` 76 | 77 | ## Usage 78 | 79 | After initial setup, logging is as easy as: 80 | ```python 81 | # Simplest use case 82 | log.info('My Sample Log Line') 83 | 84 | # Add a custom level 85 | log.info('My Sample Log Line', extra={ 'level': 'MyCustomLevel' }) 86 | 87 | # Include an App name with this specific log 88 | log.info('My Sample Log Line', extra={ 'level': 'Warn', 'app': 'myAppName'}) 89 | 90 | # Pass associated objects along as metadata 91 | meta = { 92 | 'foo': 'bar', 93 | 'nested': { 94 | 'nest1': 'nested text' 95 | } 96 | } 97 | 98 | opts = { 99 | 'level': 'warn', 100 | 'meta': meta 101 | } 102 | 103 | log.info('My Sample Log Line', extra=opts) 104 | ``` 105 | 106 | ### Usage with fileConfig 107 | 108 | To use [LogDNAHandler](#logdnahandlerkey-string-options-dict) with [fileConfig][] (e.g., in a Django `settings.py` file): 109 | 110 | ```python 111 | import os 112 | import logging 113 | from logdna import LogDNAHandler # required to register `logging.handlers.LogDNAHandler` 114 | 115 | LOGGING = { 116 | # Other logging settings... 117 | 'handlers': { 118 | 'logdna': { 119 | 'level': logging.DEBUG, 120 | 'class': 'logging.handlers.LogDNAHandler', 121 | 'key': os.environ.get('LOGDNA_INGESTION_KEY'), 122 | 'options': { 123 | 'app': '', 124 | 'env': os.environ.get('ENVIRONMENT'), 125 | 'index_meta': , 126 | }, 127 | }, 128 | }, 129 | 'loggers': { 130 | '': { 131 | 'handlers': ['logdna'], 132 | 'level': logging.DEBUG 133 | }, 134 | }, 135 | } 136 | ``` 137 | 138 | (This example assumes you have set environment variables for `ENVIRONMENT` and `LOGDNA_INGESTION_KEY`.) 139 | 140 | ## API 141 | 142 | ### LogDNAHandler(key: [string][], [options: [dict][]]) 143 | 144 | #### key 145 | 146 | * _**Required**_ 147 | * Type: [string][] 148 | * Values: `` 149 | 150 | The [LogDNA API Key](https://app.logdna.com/manage/profile) associated with your account. 151 | 152 | #### options 153 | 154 | ##### app 155 | 156 | * _Optional_ 157 | * Type: [string][] 158 | * Default: `''` 159 | * Values: `` 160 | 161 | The default app named that is included in every every log line sent through this instance. 162 | 163 | ##### env 164 | 165 | * _Optional_ 166 | * Type: [string][] 167 | * Default: `''` 168 | * Values: `` 169 | 170 | The default env passed along with every log sent through this instance. 171 | 172 | ##### hostname 173 | 174 | * _Optional_ 175 | * Type: [string][] 176 | * Default: `''` 177 | * Values: `` 178 | 179 | The default hostname passed along with every log sent through this instance. 180 | 181 | ##### include_standard_meta 182 | 183 | * _Optional_ 184 | * Type: [bool][] 185 | * Default: `False` 186 | 187 | Python [LogRecord][] objects includes language-specific information that may be useful metadata in logs. 188 | Setting `include_standard_meta` to `True` automatically populates meta objects with `name`, `pathname`, and `lineno` 189 | from the [LogRecord][]. 190 | 191 | *WARNING* This option is deprecated and will be removed in the upcoming major release. 192 | 193 | ##### index_meta 194 | 195 | * _Optional_ 196 | * Type: [bool][] 197 | * Default: `False` 198 | 199 | We allow meta objects to be passed with each line. By default these meta objects are stringified and not searchable, and are only displayed for informational purposes. 200 | 201 | If this option is set to True then meta objects are parsed and searchable up to three levels deep. Any fields deeper than three levels are stringified and cannot be searched. 202 | 203 | *WARNING* If this option is True, your metadata objects MUST have consistent types across all log messages or the metadata object might not be parsed properly. 204 | 205 | ##### level 206 | 207 | * _Optional_ 208 | * Type: [string][] 209 | * Default: `Info` 210 | * Values: `Debug`, `Trace`, `Info`, `Warn`, `Error`, `Fatal`, `` 211 | 212 | The default level passed along with every log sent through this instance. 213 | 214 | ##### verbose 215 | 216 | * _Optional_ 217 | * Type: [string][] or [bool][] 218 | * Default: `True` 219 | * Values: `False` or any level 220 | 221 | Sets the verbosity of log statements for failures. 222 | 223 | ##### request_timeout 224 | 225 | * _Optional_ 226 | * Type: [int][] 227 | * Default: `30000` 228 | 229 | The amount of time (in ms) the request should wait for LogDNA to respond before timing out. 230 | 231 | ##### tags 232 | 233 | * _Optional_ 234 | * Type: [list][]<[string][]> 235 | * Default: `[]` 236 | 237 | List of tags used to dynamically group hosts. More information on tags is available at [How Do I Use Host Tags?](https://docs.logdna.com/docs/logdna-agent#section-how-do-i-use-host-tags-) 238 | 239 | ##### url 240 | 241 | * _Optional_ 242 | * Type: [string][] 243 | * Default: `'https://logs.logdna.com/logs/ingest'` 244 | 245 | A custom ingestion endpoint to stream log lines into. 246 | 247 | ##### custom_fields 248 | 249 | * _Optional_ 250 | * Type: [list][]<[string][]> 251 | * Default: `['args', 'name', 'pathname', 'lineno']` 252 | 253 | List of fields out of `record` object to include in the `meta` object. By default, `args`, `name`, `pathname`, and `lineno` will be included. 254 | 255 | ##### log_error_response 256 | 257 | * _Optional_ 258 | * Type: [bool][] 259 | * Default: `False` 260 | 261 | Enables logging of the API response when an HTTP error is encountered 262 | 263 | ### log(line, [options]) 264 | 265 | #### line 266 | 267 | * _Required_ 268 | * Type: [string][] 269 | * Default: `''` 270 | 271 | The log line to be sent to LogDNA. 272 | 273 | #### options 274 | 275 | ##### level 276 | 277 | * _Optional_ 278 | * Type: [string][] 279 | * Default: `Info` 280 | * Values: `Debug`, `Trace`, `Info`, `Warn`, `Error`, `Fatal`, `` 281 | 282 | The level passed along with this log line. 283 | 284 | ##### app 285 | 286 | * _Optional_ 287 | * Type: [string][] 288 | * Default: `''` 289 | * Values: `` 290 | 291 | The app passed along with this log line. 292 | 293 | ##### env 294 | 295 | * _Optional_ 296 | * Type: [string][] 297 | * Default: `''` 298 | * Values: `` 299 | 300 | The environment passed with this log line. 301 | 302 | ##### meta 303 | 304 | * _Optional_ 305 | * Type: [dict][] 306 | * Default: `None` 307 | 308 | A standard dictonary containing additional metadata about the log line that is passed. Please ensure values are JSON serializable. 309 | 310 | **NOTE**: Values that are not JSON serializable will be removed and the respective keys will be added to the `__errors` string. 311 | 312 | ##### index_meta 313 | 314 | * _Optional_ 315 | * Type: [bool][] 316 | * Default: `False` 317 | 318 | We allow meta objects to be passed with each line. By default these meta objects will be stringified and will not be 319 | searchable, but will be displayed for informational purposes. 320 | 321 | If this option is turned to true then meta objects will be parsed and will be searchable up to three levels deep. Any fields deeper than three levels will be stringified and cannot be searched. 322 | 323 | *WARNING* When this option is true, your metadata objects across all types of log messages MUST have consistent types or the metadata object may not be parsed properly! 324 | 325 | ##### timestamp 326 | 327 | * _Optional_ 328 | * Type: [float][] 329 | * Default: [time.time()][] 330 | 331 | The time in seconds since the epoch to use for the log timestamp. It must be within one day or current time - if it is not, it is ignored and time.time() is used in its place. 332 | 333 | 334 | ## Development 335 | 336 | This project makes use of the [poetry][] package manager for local development. 337 | 338 | ```shell 339 | $ poetry install 340 | ``` 341 | 342 | ### Scripts 343 | 344 | **lint** 345 | Run linting rules w/o attempting to fix them 346 | 347 | ```shell 348 | $ poetry run task lint 349 | ``` 350 | 351 | 352 | **lint:fix** 353 | 354 | Run lint rules against all local python files and attempt to fix where possible. 355 | 356 | 357 | ```shell 358 | $ poetry run task lint:fix 359 | ``` 360 | 361 | **test**: 362 | 363 | Runs all unit tests and generates coverage reports 364 | 365 | ```shell 366 | poetry run task test 367 | ``` 368 | 369 | ## Contributors ✨ 370 | 371 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 |

Muaz Siddiqui

💻 📖 ⚠️

Samir Musali

💻 📖 ⚠️

vilyapilya

💻 🚧 ⚠️

Mike Hu

📖

Eric Satterwhite

💻 📖 ⚠️ 🔧

Łukasz Bołdys (Lukasz Boldys)

💻 🐛

Ryan

📖

Mike Huang

💻 🐛

Dan Maas

💻

DChai

📖

Jakob de Maeyer

💻

Andrey Babak

💻

Mike S

💻 📖

Brennan Ashton

💻

Christian Hotz-Behofsits

💻 🐛

Kurtiss Hare

🐛

Alexey Kinev

🐛

matthiasfru

🐛
402 | 403 | 404 | 405 | 406 | 407 | 408 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 409 | 410 | ## License 411 | 412 | MIT © [LogDNA](https://logdna.com/) 413 | Copyright © 2017 [LogDNA][], released under an MIT license. See the [LICENSE](./LICENSE) file and [https://opensource.org/licenses/MIT](https://opensource.org/licenses/MIT) 414 | 415 | 416 | *Happy Logging!* 417 | 418 | [bool]: https://docs.python.org/3/library/stdtypes.html#boolean-values 419 | [dict]: https://docs.python.org/3/library/stdtypes.html#mapping-types-dict 420 | [int]: https://docs.python.org/3/library/functions.html#int 421 | [float]: https://docs.python.org/3/library/functions.html#float 422 | [string]: https://docs.python.org/3/library/string.html 423 | [list]: https://docs.python.org/3/library/stdtypes.html#list 424 | [time.time()]: https://docs.python.org/3/library/time.html?#time.time 425 | [poetry]: https://python-poetry.org 426 | [LogDNA]: https://logdna.com/ 427 | [LogRecord]: https://docs.python.org/2/library/logging.html#logrecord-objects 428 | [fileConfig]: https://docs.python.org/2/library/logging.config.html#logging.config.fileConfig 429 | -------------------------------------------------------------------------------- /logdna/VERSION: -------------------------------------------------------------------------------- 1 | 1.18.12 2 | -------------------------------------------------------------------------------- /logdna/__init__.py: -------------------------------------------------------------------------------- 1 | from .logdna import LogDNAHandler 2 | __all__ = ['LogDNAHandler'] 3 | 4 | # Publish this class to the "logging.handlers" module so that it can be use 5 | # from a logging config file via logging.config.fileConfig(). 6 | import logging.handlers 7 | 8 | logging.handlers.LogDNAHandler = LogDNAHandler 9 | -------------------------------------------------------------------------------- /logdna/configs.py: -------------------------------------------------------------------------------- 1 | from os import path, sep 2 | 3 | with open("{p}{s}VERSION".format(p=path.abspath(path.dirname(__file__)), 4 | s=sep)) as f: 5 | version = f.read().strip('\n') 6 | 7 | defaults = { 8 | 'DEFAULT_REQUEST_TIMEOUT': 30, 9 | 'FLUSH_INTERVAL_SECS': 0.25, 10 | 'FLUSH_LIMIT': 2 * 1024 * 1024, 11 | 'MAX_CONCURRENT_REQUESTS': 10, 12 | 'MAX_RETRY_ATTEMPTS': 3, 13 | 'MAX_RETRY_JITTER': 0.5, 14 | 'META_FIELDS': ['args', 'name', 'pathname', 'lineno'], 15 | 'LOGDNA_URL': 'https://logs.logdna.com/logs/ingest', 16 | 'BUF_RETENTION_LIMIT': 4 * 1024 * 1024, 17 | 'RETRY_INTERVAL_SECS': 5, 18 | 'USER_AGENT': 'python/%s' % version 19 | } 20 | -------------------------------------------------------------------------------- /logdna/logdna.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import requests 3 | import socket 4 | import sys 5 | import threading 6 | import time 7 | 8 | from concurrent.futures import ThreadPoolExecutor 9 | 10 | from .configs import defaults 11 | from .utils import sanitize_meta, get_ip, normalize_list_option 12 | 13 | 14 | class LogDNAHandler(logging.Handler): 15 | def __init__(self, key, options={}): 16 | # Setup Handler 17 | logging.Handler.__init__(self) 18 | 19 | # Set Internal Logger 20 | self.internal_handler = logging.StreamHandler(sys.stdout) 21 | self.internal_handler.setLevel(logging.DEBUG) 22 | self.internalLogger = logging.getLogger('internal') 23 | self.internalLogger.addHandler(self.internal_handler) 24 | self.internalLogger.setLevel(logging.DEBUG) 25 | 26 | # Set the Custom Variables 27 | self.key = key 28 | self.hostname = options.get('hostname', socket.gethostname()) 29 | self.ip = options.get('ip', get_ip()) 30 | self.mac = options.get('mac', None) 31 | self.loglevel = options.get('level', 'info') 32 | self.app = options.get('app', '') 33 | self.env = options.get('env', '') 34 | self.tags = normalize_list_option(options, 'tags') 35 | self.custom_fields = normalize_list_option(options, 'custom_fields') 36 | self.custom_fields += defaults['META_FIELDS'] 37 | self.log_error_response = options.get('log_error_response', False) 38 | 39 | # Set the Connection Variables 40 | self.url = options.get('url', defaults['LOGDNA_URL']) 41 | self.request_timeout = options.get('request_timeout', 42 | defaults['DEFAULT_REQUEST_TIMEOUT']) 43 | self.user_agent = options.get('user_agent', defaults['USER_AGENT']) 44 | self.max_retry_attempts = options.get('max_retry_attempts', 45 | defaults['MAX_RETRY_ATTEMPTS']) 46 | self.max_retry_jitter = options.get('max_retry_jitter', 47 | defaults['MAX_RETRY_JITTER']) 48 | self.max_concurrent_requests = options.get( 49 | 'max_concurrent_requests', defaults['MAX_CONCURRENT_REQUESTS']) 50 | self.retry_interval_secs = options.get('retry_interval_secs', 51 | defaults['RETRY_INTERVAL_SECS']) 52 | 53 | # Set the Flush-related Variables 54 | self.buf = [] 55 | self.buf_size = 0 56 | 57 | self.include_standard_meta = options.get('include_standard_meta', None) 58 | 59 | if self.include_standard_meta is not None: 60 | self.internalLogger.debug( 61 | '"include_standard_meta" option will be deprecated ' + 62 | 'removed in the upcoming major release') 63 | 64 | self.index_meta = options.get('index_meta', False) 65 | self.flush_limit = options.get('flush_limit', defaults['FLUSH_LIMIT']) 66 | self.flush_interval_secs = options.get('flush_interval', 67 | defaults['FLUSH_INTERVAL_SECS']) 68 | self.buf_retention_limit = options.get('buf_retention_limit', 69 | defaults['BUF_RETENTION_LIMIT']) 70 | 71 | # Set up the Thread Pools 72 | self.worker_thread_pool = ThreadPoolExecutor() 73 | self.request_thread_pool = ThreadPoolExecutor( 74 | max_workers=self.max_concurrent_requests) 75 | 76 | self.setLevel(logging.DEBUG) 77 | self._lock = threading.RLock() 78 | 79 | self.flusher = None 80 | 81 | def start_flusher(self): 82 | if not self.flusher: 83 | self.flusher = threading.Timer( 84 | self.flush_interval_secs, self.flush) 85 | self.flusher.start() 86 | 87 | def close_flusher(self): 88 | if self.flusher: 89 | self.flusher.cancel() 90 | self.flusher = None 91 | 92 | def buffer_log(self, message): 93 | if self.worker_thread_pool: 94 | try: 95 | self.worker_thread_pool.submit(self.buffer_log_sync, message) 96 | except RuntimeError: 97 | self.buffer_log_sync(message) 98 | except Exception as e: 99 | self.internalLogger.debug('Error in calling buffer_log: %s', e) 100 | 101 | def buffer_log_sync(self, message): 102 | # Attempt to acquire lock to write to buffer 103 | if self._lock.acquire(blocking=True): 104 | try: 105 | msglen = len(message['line']) 106 | if self.buf_size + msglen < self.buf_retention_limit: 107 | self.buf.append(message) 108 | self.buf_size += msglen 109 | else: 110 | self.internalLogger.debug( 111 | 'The buffer size exceeded the limit: %s', 112 | self.buf_retention_limit) 113 | 114 | if self.buf_size >= self.flush_limit: 115 | self.close_flusher() 116 | self.flush() 117 | else: 118 | self.start_flusher() 119 | except Exception as e: 120 | self.internalLogger.exception(f'Error in buffer_log_sync: {e}') 121 | finally: 122 | self._lock.release() 123 | 124 | def flush(self): 125 | self.schedule_flush_sync() 126 | 127 | def schedule_flush_sync(self, should_block=False): 128 | if self.request_thread_pool: 129 | try: 130 | self.request_thread_pool.submit( 131 | self.try_lock_and_do_flush_request, should_block) 132 | except RuntimeError: 133 | self.try_lock_and_do_flush_request(should_block) 134 | except Exception as e: 135 | self.internalLogger.debug( 136 | 'Error in calling try_lock_and_do_flush_request: %s', e) 137 | 138 | def try_lock_and_do_flush_request(self, should_block=False): 139 | local_buf = [] 140 | if self._lock.acquire(blocking=should_block): 141 | if not self.buf: 142 | self.close_flusher() 143 | self._lock.release() 144 | return 145 | 146 | local_buf = self.buf.copy() 147 | self.buf.clear() 148 | self.buf_size = 0 149 | if local_buf: 150 | self.close_flusher() 151 | self._lock.release() 152 | 153 | if local_buf: 154 | self.try_request(local_buf) 155 | 156 | def try_request(self, buf): 157 | data = {'e': 'ls', 'ls': buf} 158 | retries = 0 159 | while retries < self.max_retry_attempts: 160 | retries += 1 161 | if self.send_request(data): 162 | break 163 | 164 | sleep_time = self.retry_interval_secs * (1 << (retries - 1)) 165 | sleep_time += self.max_retry_jitter 166 | time.sleep(sleep_time) 167 | 168 | if retries >= self.max_retry_attempts: 169 | self.internalLogger.debug( 170 | 'Flush exceeded %s tries. Discarding flush buffer', 171 | self.max_retry_attempts) 172 | 173 | def send_request(self, data): # noqa: max-complexity: 13 174 | """ 175 | Send log data to LogDNA server 176 | Returns: 177 | True - discard flush buffer 178 | False - retry, keep flush buffer 179 | """ 180 | try: 181 | headers = { 182 | 'user-agent': self.user_agent, 183 | 'apikey': self.key 184 | } 185 | response = requests.post(url=self.url, 186 | json=data, 187 | params={ 188 | 'hostname': self.hostname, 189 | 'ip': self.ip, 190 | 'mac': self.mac, 191 | 'tags': self.tags, 192 | 'now': int(time.time() * 1000) 193 | }, 194 | stream=True, 195 | allow_redirects=True, 196 | timeout=self.request_timeout, 197 | headers=headers) 198 | 199 | status_code = response.status_code 200 | ''' 201 | response code: 202 | 1XX unexpected status 203 | 200 expected status, OK 204 | 2XX unexpected status 205 | 301 302 303 unexpected status, 206 | per "allow_redirects=True" 207 | 3XX unexpected status 208 | 401, 403 expected client error, 209 | invalid ingestion key 210 | 429 expected server error, 211 | "client error", transient 212 | 4XX unexpected client error 213 | 500 502 503 504 507 expected server error, transient 214 | 5XX unexpected server error 215 | handling: 216 | expected status discard flush buffer 217 | unexpected status log + discard flush buffer 218 | expected client error log + discard flush buffer 219 | unexpected client error log + discard flush buffer 220 | expected server error log + retry 221 | unexpected server error log + discard flush buffer 222 | ''' 223 | if status_code == 200: 224 | return True # discard 225 | 226 | if isinstance(response.reason, bytes): 227 | # We attempt to decode utf-8 first because some servers 228 | # choose to localize their reason strings. If the string 229 | # isn't utf-8, we fall back to iso-8859-1 for all other 230 | # encodings. (See PR #3538) 231 | try: 232 | reason = response.reason.decode('utf-8') 233 | except UnicodeDecodeError: 234 | reason = response.reason.decode('iso-8859-1') 235 | else: 236 | reason = response.reason 237 | 238 | if 200 < status_code <= 399: 239 | self.internalLogger.debug('Unexpected response: %s. ' + 240 | 'Discarding flush buffer', 241 | reason) 242 | if self.log_error_response: 243 | self.internalLogger.debug( 244 | 'Error Response: %s', response.text) 245 | return True # discard 246 | 247 | if status_code in [401, 403]: 248 | self.internalLogger.debug( 249 | 'Please provide a valid ingestion key. ' + 250 | 'Discarding flush buffer') 251 | if self.log_error_response: 252 | self.internalLogger.debug( 253 | 'Error Response: %s', response.text) 254 | return True # discard 255 | 256 | if status_code == 429: 257 | self.internalLogger.debug('Client Error: %s. Retrying...', 258 | reason) 259 | if self.log_error_response: 260 | self.internalLogger.debug( 261 | 'Error Response: %s', response.text) 262 | return False # retry 263 | 264 | if 400 <= status_code <= 499: 265 | self.internalLogger.debug('Client Error: %s. ' + 266 | 'Discarding flush buffer', 267 | reason) 268 | if self.log_error_response: 269 | self.internalLogger.debug( 270 | 'Error Response: %s', response.text) 271 | return True # discard 272 | 273 | if status_code in [500, 502, 503, 504, 507]: 274 | self.internalLogger.debug('Server Error: %s. Retrying...', 275 | reason) 276 | if self.log_error_response: 277 | self.internalLogger.debug( 278 | 'Error Response: %s', response.text) 279 | return False # retry 280 | 281 | self.internalLogger.debug('The request failed: %s.' + 282 | 'Discarding flush buffer', 283 | reason) 284 | 285 | except requests.exceptions.Timeout as timeout: 286 | self.internalLogger.debug('Timeout Error: %s. Retrying...', 287 | timeout) 288 | return False # retry 289 | 290 | except requests.exceptions.RequestException as exception: 291 | self.internalLogger.debug( 292 | 'Error sending logs %s. Discarding flush buffer', exception) 293 | 294 | return True # discard 295 | 296 | def emit(self, record): 297 | msg = self.format(record) 298 | record = record.__dict__ 299 | message = { 300 | 'hostname': self.hostname, 301 | 'timestamp': int(time.time() * 1000), 302 | 'line': msg, 303 | 'level': record['levelname'] or self.loglevel, 304 | 'app': self.app or record['module'], 305 | 'env': self.env, 306 | 'meta': {} 307 | } 308 | 309 | for key in self.custom_fields: 310 | if key in record: 311 | if isinstance(record[key], tuple): 312 | message['meta'][key] = list(record[key]) 313 | elif record[key] is not None: 314 | message['meta'][key] = record[key] 315 | 316 | message['meta'] = sanitize_meta(message['meta'], self.index_meta) 317 | 318 | opts = {} 319 | if 'args' in record and not isinstance(record['args'], tuple): 320 | opts = record['args'] 321 | 322 | for key in ['app', 'env', 'hostname', 'level', 'timestamp']: 323 | if key in opts: 324 | message[key] = opts[key] 325 | 326 | self.buffer_log(message) 327 | 328 | def close(self): 329 | # Close the flusher 330 | self.close_flusher() 331 | 332 | # First gracefully shut down any threads that are still attempting 333 | # to add log messages to the buffer. This ensures that we don't lose 334 | # any log messages that are in the process of being added to the 335 | # buffer. 336 | if self.worker_thread_pool: 337 | self.worker_thread_pool.shutdown(wait=True) 338 | self.worker_thread_pool = None 339 | 340 | # Manually force a flush of any remaining log messages in the buffer. 341 | # We block here to ensure that the flush completes prior to the 342 | # application exiting and because the probability of this 343 | # introducing a noticeable delay is very low because close() is only 344 | # called when the logger and application are shutting down. 345 | self.schedule_flush_sync(should_block=True) 346 | 347 | # Finally, shut down the thread pool that was used to send the log 348 | # messages to the server. We can assume at this point that all log 349 | # messages that were in the buffer prior to the worker threads 350 | # shutting down have been sent to the server. 351 | if self.request_thread_pool: 352 | self.request_thread_pool.shutdown(wait=True) 353 | self.request_thread_pool = None 354 | 355 | logging.Handler.close(self) 356 | -------------------------------------------------------------------------------- /logdna/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import socket 3 | 4 | 5 | def is_jsonable(obj): 6 | try: 7 | json.dumps(obj) 8 | return True 9 | except (TypeError, OverflowError, ValueError): 10 | return False 11 | 12 | 13 | def normalize_list_option(options, key): 14 | value = options.get(key, []) 15 | if isinstance(value, str): 16 | value = [val.strip() for val in value.split(',')] 17 | elif not isinstance(value, list): 18 | value = [] 19 | return value 20 | 21 | 22 | def sanitize_meta(meta, index_meta=False): 23 | if not index_meta: 24 | if is_jsonable(meta): 25 | return json.dumps(meta) 26 | 27 | return { 28 | '__errors': 'Meta cannot be serialized into JSON-formatted string' 29 | } 30 | 31 | keys_to_sanitize = [] 32 | for key, value in meta.items(): 33 | if not is_jsonable(value): 34 | keys_to_sanitize.append(key) 35 | if keys_to_sanitize: 36 | for key in keys_to_sanitize: 37 | del meta[key] 38 | meta['__errors'] = 'These keys have been sanitized: ' + ', '.join( 39 | keys_to_sanitize) 40 | return meta 41 | 42 | 43 | def get_ip(): 44 | s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) 45 | try: 46 | # doesn't even have to be reachable 47 | s.connect(('10.255.255.255', 1)) 48 | ip = s.getsockname()[0] 49 | except Exception: 50 | ip = '127.0.0.1' 51 | finally: 52 | s.close() 53 | return ip 54 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | [[package]] 2 | name = "appnope" 3 | version = "0.1.3" 4 | description = "Disable App Nap on macOS >= 10.9" 5 | category = "dev" 6 | optional = false 7 | python-versions = "*" 8 | 9 | [[package]] 10 | name = "attrs" 11 | version = "22.2.0" 12 | description = "Classes Without Boilerplate" 13 | category = "dev" 14 | optional = false 15 | python-versions = ">=3.6" 16 | 17 | [package.extras] 18 | cov = ["attrs", "coverage-enable-subprocess", "coverage[toml] (>=5.3)"] 19 | dev = ["attrs"] 20 | docs = ["furo", "sphinx", "myst-parser", "zope.interface", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier"] 21 | tests = ["attrs", "zope.interface"] 22 | tests-no-zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"] 23 | tests_no_zope = ["hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist", "cloudpickle", "mypy (>=0.971,<0.990)", "pytest-mypy-plugins"] 24 | 25 | [[package]] 26 | name = "backcall" 27 | version = "0.2.0" 28 | description = "Specifications for callback functions passed in to an API" 29 | category = "dev" 30 | optional = false 31 | python-versions = "*" 32 | 33 | [[package]] 34 | name = "bleach" 35 | version = "6.0.0" 36 | description = "An easy safelist-based HTML-sanitizing tool." 37 | category = "dev" 38 | optional = false 39 | python-versions = ">=3.7" 40 | 41 | [package.dependencies] 42 | six = ">=1.9.0" 43 | webencodings = "*" 44 | 45 | [package.extras] 46 | css = ["tinycss2 (>=1.1.0,<1.2)"] 47 | 48 | [[package]] 49 | name = "certifi" 50 | version = "2022.12.7" 51 | description = "Python package for providing Mozilla's CA Bundle." 52 | category = "main" 53 | optional = false 54 | python-versions = ">=3.6" 55 | 56 | [[package]] 57 | name = "cffi" 58 | version = "1.15.1" 59 | description = "Foreign Function Interface for Python calling C code." 60 | category = "dev" 61 | optional = false 62 | python-versions = "*" 63 | 64 | [package.dependencies] 65 | pycparser = "*" 66 | 67 | [[package]] 68 | name = "charset-normalizer" 69 | version = "3.0.1" 70 | description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." 71 | category = "main" 72 | optional = false 73 | python-versions = "*" 74 | 75 | [[package]] 76 | name = "click" 77 | version = "8.1.3" 78 | description = "Composable command line interface toolkit" 79 | category = "dev" 80 | optional = false 81 | python-versions = ">=3.7" 82 | 83 | [package.dependencies] 84 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 85 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 86 | 87 | [[package]] 88 | name = "click-log" 89 | version = "0.4.0" 90 | description = "Logging integration for Click" 91 | category = "dev" 92 | optional = false 93 | python-versions = "*" 94 | 95 | [package.dependencies] 96 | click = "*" 97 | 98 | [[package]] 99 | name = "colorama" 100 | version = "0.4.6" 101 | description = "Cross-platform colored terminal text." 102 | category = "dev" 103 | optional = false 104 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 105 | 106 | [[package]] 107 | name = "coverage" 108 | version = "5.5" 109 | description = "Code coverage measurement for Python" 110 | category = "dev" 111 | optional = false 112 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, <4" 113 | 114 | [package.extras] 115 | toml = ["toml"] 116 | 117 | [[package]] 118 | name = "cryptography" 119 | version = "39.0.0" 120 | description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." 121 | category = "dev" 122 | optional = false 123 | python-versions = ">=3.6" 124 | 125 | [package.dependencies] 126 | cffi = ">=1.12" 127 | 128 | [package.extras] 129 | docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1,!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] 130 | docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"] 131 | pep8test = ["black", "ruff"] 132 | sdist = ["setuptools-rust (>=0.11.4)"] 133 | ssh = ["bcrypt (>=3.1.5)"] 134 | test = ["pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601", "pytz", "hypothesis (>=1.11.4,!=3.79.2)"] 135 | 136 | [[package]] 137 | name = "decorator" 138 | version = "5.1.1" 139 | description = "Decorators for Humans" 140 | category = "dev" 141 | optional = false 142 | python-versions = ">=3.5" 143 | 144 | [[package]] 145 | name = "docutils" 146 | version = "0.19" 147 | description = "Docutils -- Python Documentation Utilities" 148 | category = "dev" 149 | optional = false 150 | python-versions = ">=3.7" 151 | 152 | [[package]] 153 | name = "dotty-dict" 154 | version = "1.3.1" 155 | description = "Dictionary wrapper for quick access to deeply nested keys." 156 | category = "dev" 157 | optional = false 158 | python-versions = ">=3.5,<4.0" 159 | 160 | [[package]] 161 | name = "exceptiongroup" 162 | version = "1.1.0" 163 | description = "Backport of PEP 654 (exception groups)" 164 | category = "dev" 165 | optional = false 166 | python-versions = ">=3.7" 167 | 168 | [package.extras] 169 | test = ["pytest (>=6)"] 170 | 171 | [[package]] 172 | name = "flake8" 173 | version = "3.9.2" 174 | description = "the modular source code checker: pep8 pyflakes and co" 175 | category = "dev" 176 | optional = false 177 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,>=2.7" 178 | 179 | [package.dependencies] 180 | importlib-metadata = {version = "*", markers = "python_version < \"3.8\""} 181 | mccabe = ">=0.6.0,<0.7.0" 182 | pycodestyle = ">=2.7.0,<2.8.0" 183 | pyflakes = ">=2.3.0,<2.4.0" 184 | 185 | [[package]] 186 | name = "gitdb" 187 | version = "4.0.10" 188 | description = "Git Object Database" 189 | category = "dev" 190 | optional = false 191 | python-versions = ">=3.7" 192 | 193 | [package.dependencies] 194 | smmap = ">=3.0.1,<6" 195 | 196 | [[package]] 197 | name = "gitpython" 198 | version = "3.1.30" 199 | description = "GitPython is a python library used to interact with Git repositories" 200 | category = "dev" 201 | optional = false 202 | python-versions = ">=3.7" 203 | 204 | [package.dependencies] 205 | gitdb = ">=4.0.1,<5" 206 | typing-extensions = {version = ">=3.7.4.3", markers = "python_version < \"3.8\""} 207 | 208 | [[package]] 209 | name = "idna" 210 | version = "3.4" 211 | description = "Internationalized Domain Names in Applications (IDNA)" 212 | category = "main" 213 | optional = false 214 | python-versions = ">=3.5" 215 | 216 | [[package]] 217 | name = "importlib-metadata" 218 | version = "6.0.0" 219 | description = "Read metadata from Python packages" 220 | category = "dev" 221 | optional = false 222 | python-versions = ">=3.7" 223 | 224 | [package.dependencies] 225 | typing-extensions = {version = ">=3.6.4", markers = "python_version < \"3.8\""} 226 | zipp = ">=0.5" 227 | 228 | [package.extras] 229 | docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"] 230 | perf = ["ipython"] 231 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "packaging", "pyfakefs", "flufl.flake8", "pytest-perf (>=0.9.2)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8", "importlib-resources (>=1.3)"] 232 | 233 | [[package]] 234 | name = "importlib-resources" 235 | version = "5.10.2" 236 | description = "Read resources from Python packages" 237 | category = "dev" 238 | optional = false 239 | python-versions = ">=3.7" 240 | 241 | [package.dependencies] 242 | zipp = {version = ">=3.1.0", markers = "python_version < \"3.10\""} 243 | 244 | [package.extras] 245 | docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "sphinx-lint", "jaraco.tidelift (>=1.4)"] 246 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8"] 247 | 248 | [[package]] 249 | name = "iniconfig" 250 | version = "2.0.0" 251 | description = "brain-dead simple config-ini parsing" 252 | category = "dev" 253 | optional = false 254 | python-versions = ">=3.7" 255 | 256 | [[package]] 257 | name = "invoke" 258 | version = "1.7.3" 259 | description = "Pythonic task execution" 260 | category = "dev" 261 | optional = false 262 | python-versions = "*" 263 | 264 | [[package]] 265 | name = "ipdb" 266 | version = "0.13.11" 267 | description = "IPython-enabled pdb" 268 | category = "dev" 269 | optional = false 270 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 271 | 272 | [package.dependencies] 273 | decorator = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\" or python_version >= \"3.11\""} 274 | ipython = {version = ">=7.31.1", markers = "python_version > \"3.6\" and python_version < \"3.11\" or python_version >= \"3.11\""} 275 | tomli = {version = "*", markers = "python_version > \"3.6\" and python_version < \"3.11\""} 276 | 277 | [[package]] 278 | name = "ipython" 279 | version = "7.34.0" 280 | description = "IPython: Productive Interactive Computing" 281 | category = "dev" 282 | optional = false 283 | python-versions = ">=3.7" 284 | 285 | [package.dependencies] 286 | appnope = {version = "*", markers = "sys_platform == \"darwin\""} 287 | backcall = "*" 288 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 289 | decorator = "*" 290 | jedi = ">=0.16" 291 | matplotlib-inline = "*" 292 | pexpect = {version = ">4.3", markers = "sys_platform != \"win32\""} 293 | pickleshare = "*" 294 | prompt-toolkit = ">=2.0.0,<3.0.0 || >3.0.0,<3.0.1 || >3.0.1,<3.1.0" 295 | pygments = "*" 296 | traitlets = ">=4.2" 297 | 298 | [package.extras] 299 | all = ["Sphinx (>=1.3)", "ipykernel", "ipyparallel", "ipywidgets", "nbconvert", "nbformat", "nose (>=0.10.1)", "notebook", "numpy (>=1.17)", "pygments", "qtconsole", "requests", "testpath"] 300 | doc = ["Sphinx (>=1.3)"] 301 | kernel = ["ipykernel"] 302 | nbconvert = ["nbconvert"] 303 | nbformat = ["nbformat"] 304 | notebook = ["notebook", "ipywidgets"] 305 | parallel = ["ipyparallel"] 306 | qtconsole = ["qtconsole"] 307 | test = ["nose (>=0.10.1)", "requests", "testpath", "pygments", "nbformat", "ipykernel", "numpy (>=1.17)"] 308 | 309 | [[package]] 310 | name = "jaraco.classes" 311 | version = "3.2.3" 312 | description = "Utility functions for Python class constructs" 313 | category = "dev" 314 | optional = false 315 | python-versions = ">=3.7" 316 | 317 | [package.dependencies] 318 | more-itertools = "*" 319 | 320 | [package.extras] 321 | docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "jaraco.tidelift (>=1.4)"] 322 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)"] 323 | 324 | [[package]] 325 | name = "jedi" 326 | version = "0.18.2" 327 | description = "An autocompletion tool for Python that can be used for text editors." 328 | category = "dev" 329 | optional = false 330 | python-versions = ">=3.6" 331 | 332 | [package.dependencies] 333 | parso = ">=0.8.0,<0.9.0" 334 | 335 | [package.extras] 336 | docs = ["Jinja2 (==2.11.3)", "MarkupSafe (==1.1.1)", "Pygments (==2.8.1)", "alabaster (==0.7.12)", "babel (==2.9.1)", "chardet (==4.0.0)", "commonmark (==0.8.1)", "docutils (==0.17.1)", "future (==0.18.2)", "idna (==2.10)", "imagesize (==1.2.0)", "mock (==1.0.1)", "packaging (==20.9)", "pyparsing (==2.4.7)", "pytz (==2021.1)", "readthedocs-sphinx-ext (==2.1.4)", "recommonmark (==0.5.0)", "requests (==2.25.1)", "six (==1.15.0)", "snowballstemmer (==2.1.0)", "sphinx-rtd-theme (==0.4.3)", "sphinx (==1.8.5)", "sphinxcontrib-serializinghtml (==1.1.4)", "sphinxcontrib-websupport (==1.2.4)", "urllib3 (==1.26.4)"] 337 | qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] 338 | testing = ["Django (<3.1)", "attrs", "colorama", "docopt", "pytest (<7.0.0)"] 339 | 340 | [[package]] 341 | name = "jeepney" 342 | version = "0.8.0" 343 | description = "Low-level, pure Python DBus protocol wrapper." 344 | category = "dev" 345 | optional = false 346 | python-versions = ">=3.7" 347 | 348 | [package.extras] 349 | test = ["pytest", "pytest-trio", "pytest-asyncio (>=0.17)", "testpath", "trio", "async-timeout"] 350 | trio = ["trio", "async-generator"] 351 | 352 | [[package]] 353 | name = "keyring" 354 | version = "23.13.1" 355 | description = "Store and access your passwords safely." 356 | category = "dev" 357 | optional = false 358 | python-versions = ">=3.7" 359 | 360 | [package.dependencies] 361 | importlib-metadata = {version = ">=4.11.4", markers = "python_version < \"3.12\""} 362 | importlib-resources = {version = "*", markers = "python_version < \"3.9\""} 363 | "jaraco.classes" = "*" 364 | jeepney = {version = ">=0.4.2", markers = "sys_platform == \"linux\""} 365 | pywin32-ctypes = {version = ">=0.2.0", markers = "sys_platform == \"win32\""} 366 | SecretStorage = {version = ">=3.2", markers = "sys_platform == \"linux\""} 367 | 368 | [package.extras] 369 | completion = ["shtab"] 370 | docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] 371 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8"] 372 | 373 | [[package]] 374 | name = "matplotlib-inline" 375 | version = "0.1.6" 376 | description = "Inline Matplotlib backend for Jupyter" 377 | category = "dev" 378 | optional = false 379 | python-versions = ">=3.5" 380 | 381 | [package.dependencies] 382 | traitlets = "*" 383 | 384 | [[package]] 385 | name = "mccabe" 386 | version = "0.6.1" 387 | description = "McCabe checker, plugin for flake8" 388 | category = "dev" 389 | optional = false 390 | python-versions = "*" 391 | 392 | [[package]] 393 | name = "more-itertools" 394 | version = "9.0.0" 395 | description = "More routines for operating on iterables, beyond itertools" 396 | category = "dev" 397 | optional = false 398 | python-versions = ">=3.7" 399 | 400 | [[package]] 401 | name = "mslex" 402 | version = "0.3.0" 403 | description = "shlex for windows" 404 | category = "dev" 405 | optional = false 406 | python-versions = ">=3.5" 407 | 408 | [[package]] 409 | name = "packaging" 410 | version = "23.0" 411 | description = "Core utilities for Python packages" 412 | category = "dev" 413 | optional = false 414 | python-versions = ">=3.7" 415 | 416 | [[package]] 417 | name = "parso" 418 | version = "0.8.3" 419 | description = "A Python Parser" 420 | category = "dev" 421 | optional = false 422 | python-versions = ">=3.6" 423 | 424 | [package.extras] 425 | qa = ["flake8 (==3.8.3)", "mypy (==0.782)"] 426 | testing = ["docopt", "pytest (<6.0.0)"] 427 | 428 | [[package]] 429 | name = "pexpect" 430 | version = "4.8.0" 431 | description = "Pexpect allows easy control of interactive console applications." 432 | category = "dev" 433 | optional = false 434 | python-versions = "*" 435 | 436 | [package.dependencies] 437 | ptyprocess = ">=0.5" 438 | 439 | [[package]] 440 | name = "pickleshare" 441 | version = "0.7.5" 442 | description = "Tiny 'shelve'-like database with concurrency support" 443 | category = "dev" 444 | optional = false 445 | python-versions = "*" 446 | 447 | [[package]] 448 | name = "pkginfo" 449 | version = "1.9.6" 450 | description = "Query metadata from sdists / bdists / installed packages." 451 | category = "dev" 452 | optional = false 453 | python-versions = ">=3.6" 454 | 455 | [package.extras] 456 | testing = ["pytest", "pytest-cov"] 457 | 458 | [[package]] 459 | name = "pluggy" 460 | version = "1.0.0" 461 | description = "plugin and hook calling mechanisms for python" 462 | category = "dev" 463 | optional = false 464 | python-versions = ">=3.6" 465 | 466 | [package.dependencies] 467 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 468 | 469 | [package.extras] 470 | dev = ["pre-commit", "tox"] 471 | testing = ["pytest", "pytest-benchmark"] 472 | 473 | [[package]] 474 | name = "prompt-toolkit" 475 | version = "3.0.36" 476 | description = "Library for building powerful interactive command lines in Python" 477 | category = "dev" 478 | optional = false 479 | python-versions = ">=3.6.2" 480 | 481 | [package.dependencies] 482 | wcwidth = "*" 483 | 484 | [[package]] 485 | name = "psutil" 486 | version = "5.9.4" 487 | description = "Cross-platform lib for process and system monitoring in Python." 488 | category = "dev" 489 | optional = false 490 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 491 | 492 | [package.extras] 493 | test = ["ipaddress", "mock", "enum34", "pywin32", "wmi"] 494 | 495 | [[package]] 496 | name = "ptyprocess" 497 | version = "0.7.0" 498 | description = "Run a subprocess in a pseudo terminal" 499 | category = "dev" 500 | optional = false 501 | python-versions = "*" 502 | 503 | [[package]] 504 | name = "pycodestyle" 505 | version = "2.7.0" 506 | description = "Python style guide checker" 507 | category = "dev" 508 | optional = false 509 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 510 | 511 | [[package]] 512 | name = "pycparser" 513 | version = "2.21" 514 | description = "C parser in Python" 515 | category = "dev" 516 | optional = false 517 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 518 | 519 | [[package]] 520 | name = "pyflakes" 521 | version = "2.3.1" 522 | description = "passive checker of Python programs" 523 | category = "dev" 524 | optional = false 525 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 526 | 527 | [[package]] 528 | name = "pygments" 529 | version = "2.14.0" 530 | description = "Pygments is a syntax highlighting package written in Python." 531 | category = "dev" 532 | optional = false 533 | python-versions = ">=3.6" 534 | 535 | [package.extras] 536 | plugins = ["importlib-metadata"] 537 | 538 | [[package]] 539 | name = "pytest" 540 | version = "7.2.1" 541 | description = "pytest: simple powerful testing with Python" 542 | category = "dev" 543 | optional = false 544 | python-versions = ">=3.7" 545 | 546 | [package.dependencies] 547 | attrs = ">=19.2.0" 548 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 549 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 550 | importlib-metadata = {version = ">=0.12", markers = "python_version < \"3.8\""} 551 | iniconfig = "*" 552 | packaging = "*" 553 | pluggy = ">=0.12,<2.0" 554 | tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} 555 | 556 | [package.extras] 557 | testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] 558 | 559 | [[package]] 560 | name = "pytest-cov" 561 | version = "2.12.1" 562 | description = "Pytest plugin for measuring coverage." 563 | category = "dev" 564 | optional = false 565 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" 566 | 567 | [package.dependencies] 568 | coverage = ">=5.2.1" 569 | pytest = ">=4.6" 570 | toml = "*" 571 | 572 | [package.extras] 573 | testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"] 574 | 575 | [[package]] 576 | name = "python-gitlab" 577 | version = "3.12.0" 578 | description = "Interact with GitLab API" 579 | category = "dev" 580 | optional = false 581 | python-versions = ">=3.7.0" 582 | 583 | [package.dependencies] 584 | requests = ">=2.25.0" 585 | requests-toolbelt = ">=0.9.1" 586 | 587 | [package.extras] 588 | autocompletion = ["argcomplete (>=1.10.0,<3)"] 589 | yaml = ["PyYaml (>=5.2)"] 590 | 591 | [[package]] 592 | name = "python-semantic-release" 593 | version = "7.33.0" 594 | description = "Automatic Semantic Versioning for Python projects" 595 | category = "dev" 596 | optional = false 597 | python-versions = "*" 598 | 599 | [package.dependencies] 600 | click = ">=7,<9" 601 | click-log = ">=0.3,<1" 602 | dotty-dict = ">=1.3.0,<2" 603 | gitpython = ">=3.0.8,<4" 604 | invoke = ">=1.4.1,<2" 605 | packaging = "*" 606 | python-gitlab = ">=2,<4" 607 | requests = ">=2.25,<3" 608 | semver = ">=2.10,<3" 609 | tomlkit = ">=0.10,<1.0" 610 | twine = ">=3,<4" 611 | 612 | [package.extras] 613 | dev = ["tox", "isort", "black"] 614 | docs = ["Sphinx (==1.3.6)", "Jinja2 (==3.0.3)"] 615 | mypy = ["mypy", "types-requests"] 616 | test = ["coverage (>=5,<6)", "pytest (>=7,<8)", "pytest-xdist (>=1,<2)", "pytest-mock (>=2,<3)", "responses (==0.13.3)", "mock (==1.3.0)"] 617 | 618 | [[package]] 619 | name = "pywin32-ctypes" 620 | version = "0.2.0" 621 | description = "" 622 | category = "dev" 623 | optional = false 624 | python-versions = "*" 625 | 626 | [[package]] 627 | name = "readme-renderer" 628 | version = "37.3" 629 | description = "readme_renderer is a library for rendering \"readme\" descriptions for Warehouse" 630 | category = "dev" 631 | optional = false 632 | python-versions = ">=3.7" 633 | 634 | [package.dependencies] 635 | bleach = ">=2.1.0" 636 | docutils = ">=0.13.1" 637 | Pygments = ">=2.5.1" 638 | 639 | [package.extras] 640 | md = ["cmarkgfm (>=0.8.0)"] 641 | 642 | [[package]] 643 | name = "requests" 644 | version = "2.28.2" 645 | description = "Python HTTP for Humans." 646 | category = "main" 647 | optional = false 648 | python-versions = ">=3.7, <4" 649 | 650 | [package.dependencies] 651 | certifi = ">=2017.4.17" 652 | charset-normalizer = ">=2,<4" 653 | idna = ">=2.5,<4" 654 | urllib3 = ">=1.21.1,<1.27" 655 | 656 | [package.extras] 657 | socks = ["PySocks (>=1.5.6,!=1.5.7)"] 658 | use_chardet_on_py3 = ["chardet (>=3.0.2,<6)"] 659 | 660 | [[package]] 661 | name = "requests-toolbelt" 662 | version = "0.10.1" 663 | description = "A utility belt for advanced users of python-requests" 664 | category = "dev" 665 | optional = false 666 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 667 | 668 | [package.dependencies] 669 | requests = ">=2.0.1,<3.0.0" 670 | 671 | [[package]] 672 | name = "rfc3986" 673 | version = "2.0.0" 674 | description = "Validating URI References per RFC 3986" 675 | category = "dev" 676 | optional = false 677 | python-versions = ">=3.7" 678 | 679 | [package.extras] 680 | idna2008 = ["idna"] 681 | 682 | [[package]] 683 | name = "secretstorage" 684 | version = "3.3.3" 685 | description = "Python bindings to FreeDesktop.org Secret Service API" 686 | category = "dev" 687 | optional = false 688 | python-versions = ">=3.6" 689 | 690 | [package.dependencies] 691 | cryptography = ">=2.0" 692 | jeepney = ">=0.6" 693 | 694 | [[package]] 695 | name = "semver" 696 | version = "2.13.0" 697 | description = "Python helper for Semantic Versioning (http://semver.org/)" 698 | category = "dev" 699 | optional = false 700 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" 701 | 702 | [[package]] 703 | name = "six" 704 | version = "1.16.0" 705 | description = "Python 2 and 3 compatibility utilities" 706 | category = "dev" 707 | optional = false 708 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 709 | 710 | [[package]] 711 | name = "smmap" 712 | version = "5.0.0" 713 | description = "A pure Python implementation of a sliding window memory map manager" 714 | category = "dev" 715 | optional = false 716 | python-versions = ">=3.6" 717 | 718 | [[package]] 719 | name = "tap.py" 720 | version = "3.1" 721 | description = "Test Anything Protocol (TAP) tools" 722 | category = "dev" 723 | optional = false 724 | python-versions = "*" 725 | 726 | [package.extras] 727 | yaml = ["more-itertools", "PyYAML (>=5.1)"] 728 | 729 | [[package]] 730 | name = "taskipy" 731 | version = "1.10.3" 732 | description = "tasks runner for python projects" 733 | category = "dev" 734 | optional = false 735 | python-versions = ">=3.6,<4.0" 736 | 737 | [package.dependencies] 738 | colorama = ">=0.4.4,<0.5.0" 739 | mslex = {version = ">=0.3.0,<0.4.0", markers = "sys_platform == \"win32\""} 740 | psutil = ">=5.7.2,<6.0.0" 741 | tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version >= \"3.7\" and python_version < \"4.0\""} 742 | 743 | [[package]] 744 | name = "toml" 745 | version = "0.10.2" 746 | description = "Python Library for Tom's Obvious, Minimal Language" 747 | category = "dev" 748 | optional = false 749 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 750 | 751 | [[package]] 752 | name = "tomli" 753 | version = "2.0.1" 754 | description = "A lil' TOML parser" 755 | category = "dev" 756 | optional = false 757 | python-versions = ">=3.7" 758 | 759 | [[package]] 760 | name = "tomlkit" 761 | version = "0.11.6" 762 | description = "Style preserving TOML library" 763 | category = "dev" 764 | optional = false 765 | python-versions = ">=3.6" 766 | 767 | [[package]] 768 | name = "tqdm" 769 | version = "4.64.1" 770 | description = "Fast, Extensible Progress Meter" 771 | category = "dev" 772 | optional = false 773 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,>=2.7" 774 | 775 | [package.dependencies] 776 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 777 | 778 | [package.extras] 779 | dev = ["py-make (>=0.1.0)", "twine", "wheel"] 780 | notebook = ["ipywidgets (>=6)"] 781 | slack = ["slack-sdk"] 782 | telegram = ["requests"] 783 | 784 | [[package]] 785 | name = "traitlets" 786 | version = "5.8.1" 787 | description = "Traitlets Python configuration system" 788 | category = "dev" 789 | optional = false 790 | python-versions = ">=3.7" 791 | 792 | [package.extras] 793 | docs = ["myst-parser", "pydata-sphinx-theme", "sphinx"] 794 | test = ["argcomplete (>=2.0)", "pre-commit", "pytest", "pytest-mock"] 795 | 796 | [[package]] 797 | name = "twine" 798 | version = "3.8.0" 799 | description = "Collection of utilities for publishing packages on PyPI" 800 | category = "dev" 801 | optional = false 802 | python-versions = ">=3.6" 803 | 804 | [package.dependencies] 805 | colorama = ">=0.4.3" 806 | importlib-metadata = ">=3.6" 807 | keyring = ">=15.1" 808 | pkginfo = ">=1.8.1" 809 | readme-renderer = ">=21.0" 810 | requests = ">=2.20" 811 | requests-toolbelt = ">=0.8.0,<0.9.0 || >0.9.0" 812 | rfc3986 = ">=1.4.0" 813 | tqdm = ">=4.14" 814 | urllib3 = ">=1.26.0" 815 | 816 | [[package]] 817 | name = "typing-extensions" 818 | version = "4.4.0" 819 | description = "Backported and Experimental Type Hints for Python 3.7+" 820 | category = "dev" 821 | optional = false 822 | python-versions = ">=3.7" 823 | 824 | [[package]] 825 | name = "urllib3" 826 | version = "1.26.14" 827 | description = "HTTP library with thread-safe connection pooling, file post, and more." 828 | category = "main" 829 | optional = false 830 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" 831 | 832 | [package.extras] 833 | brotli = ["brotlicffi (>=0.8.0)", "brotli (>=1.0.9)", "brotlipy (>=0.6.0)"] 834 | secure = ["pyOpenSSL (>=0.14)", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "certifi", "urllib3-secure-extra", "ipaddress"] 835 | socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] 836 | 837 | [[package]] 838 | name = "wcwidth" 839 | version = "0.2.6" 840 | description = "Measures the displayed width of unicode strings in a terminal" 841 | category = "dev" 842 | optional = false 843 | python-versions = "*" 844 | 845 | [[package]] 846 | name = "webencodings" 847 | version = "0.5.1" 848 | description = "Character encoding aliases for legacy web content" 849 | category = "dev" 850 | optional = false 851 | python-versions = "*" 852 | 853 | [[package]] 854 | name = "yapf" 855 | version = "0.30.0" 856 | description = "A formatter for Python code." 857 | category = "dev" 858 | optional = false 859 | python-versions = "*" 860 | 861 | [[package]] 862 | name = "zipp" 863 | version = "3.11.0" 864 | description = "Backport of pathlib-compatible object wrapper for zip files" 865 | category = "dev" 866 | optional = false 867 | python-versions = ">=3.7" 868 | 869 | [package.extras] 870 | docs = ["sphinx (>=3.5)", "jaraco.packaging (>=9)", "rst.linker (>=1.9)", "furo", "jaraco.tidelift (>=1.4)"] 871 | testing = ["pytest (>=6)", "pytest-checkdocs (>=2.4)", "flake8 (<5)", "pytest-cov", "pytest-enabler (>=1.3)", "jaraco.itertools", "func-timeout", "jaraco.functools", "more-itertools", "pytest-black (>=0.3.7)", "pytest-mypy (>=0.9.1)", "pytest-flake8"] 872 | 873 | [metadata] 874 | lock-version = "1.1" 875 | python-versions = "^3.7" 876 | content-hash = "2ce3c41b1d2a1222b4e53674ab5901777d35e52bcee9eaeb93ff625c74e14d31" 877 | 878 | [metadata.files] 879 | appnope = [] 880 | attrs = [] 881 | backcall = [] 882 | bleach = [] 883 | certifi = [] 884 | cffi = [] 885 | charset-normalizer = [] 886 | click = [] 887 | click-log = [] 888 | colorama = [] 889 | coverage = [] 890 | cryptography = [] 891 | decorator = [] 892 | docutils = [] 893 | dotty-dict = [] 894 | exceptiongroup = [] 895 | flake8 = [] 896 | gitdb = [] 897 | gitpython = [] 898 | idna = [] 899 | importlib-metadata = [] 900 | importlib-resources = [] 901 | iniconfig = [] 902 | invoke = [] 903 | ipdb = [] 904 | ipython = [] 905 | "jaraco.classes" = [] 906 | jedi = [] 907 | jeepney = [] 908 | keyring = [] 909 | matplotlib-inline = [] 910 | mccabe = [] 911 | more-itertools = [] 912 | mslex = [] 913 | packaging = [] 914 | parso = [] 915 | pexpect = [] 916 | pickleshare = [] 917 | pkginfo = [] 918 | pluggy = [] 919 | prompt-toolkit = [] 920 | psutil = [] 921 | ptyprocess = [] 922 | pycodestyle = [] 923 | pycparser = [] 924 | pyflakes = [] 925 | pygments = [] 926 | pytest = [] 927 | pytest-cov = [] 928 | python-gitlab = [] 929 | python-semantic-release = [] 930 | pywin32-ctypes = [] 931 | readme-renderer = [] 932 | requests = [] 933 | requests-toolbelt = [] 934 | rfc3986 = [] 935 | secretstorage = [] 936 | semver = [] 937 | six = [] 938 | smmap = [] 939 | "tap.py" = [] 940 | taskipy = [] 941 | toml = [] 942 | tomli = [] 943 | tomlkit = [] 944 | tqdm = [] 945 | traitlets = [] 946 | twine = [] 947 | typing-extensions = [] 948 | urllib3 = [] 949 | wcwidth = [] 950 | webencodings = [] 951 | yapf = [] 952 | zipp = [] 953 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "logdna" 3 | version = "1.18.12" 4 | description = 'A Python Package for Sending Logs to LogDNA' 5 | authors = ["logdna "] 6 | license = "MIT" 7 | 8 | [tool.semantic_release] 9 | version_toml = "pyproject.toml:tool.poetry.version" 10 | version_pattern = "logdna/VERSION:(\\d+\\.\\d+\\.\\d+)" 11 | branch = "master" 12 | commit_subject = "release: Version {version} [skip ci]" 13 | commit_author = "LogDNA Bot " 14 | 15 | [tool.poetry.dependencies] 16 | python = "^3.7" 17 | requests = "^2.28.1" 18 | 19 | [tool.poetry.dev-dependencies] 20 | coverage = "^5.4" 21 | "tap.py" = "^3.0" 22 | ipdb = "^0.13.4" 23 | flake8 = "^3.8.4" 24 | yapf = "^0.30.0" 25 | pytest = "^7.2.0" 26 | pytest-cov = "^2.11.1" 27 | taskipy = "^1.6.0" 28 | python-semantic-release = "^7.28.1" 29 | 30 | [tool.taskipy.tasks] 31 | pre_test = "mkdir -p coverage" 32 | test = "pytest --junitxml=coverage/test.xml --cov=logdna --cov-report=html --verbose tests/" 33 | post_test = "python scripts/json_coverage.py" 34 | lint = "flake8 --doctests" 35 | "lint:fix" = "yapf -r -i logdna scripts tests" 36 | "post_lint:fix" = "task lint" 37 | release = "semantic-release publish" 38 | 39 | [build-system] 40 | requires = ["poetry>=0.12"] 41 | build-backend = "poetry.masonry.api" 42 | 43 | [tool.pytest.ini_options] 44 | minversion = "6.0" 45 | testpaths = "tests" 46 | 47 | [tool.coverage.run] 48 | branch = true 49 | source = ["logdna"] 50 | 51 | [tool.coverage.report] 52 | fail_under = 76 53 | show_missing = true 54 | 55 | [tool.coverage.json] 56 | output = "coverage/coverage.json" 57 | 58 | [tool.coverage.html] 59 | directory = "coverage" 60 | show_contexts = true 61 | -------------------------------------------------------------------------------- /scripts/json_coverage.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os import path 3 | from coverage import Coverage 4 | 5 | ROOT = path.realpath(path.abspath(path.join(path.dirname(__file__), '..'))) 6 | COVERAGE_DIR = path.join(ROOT, 'coverage') 7 | JUNIT_PATH = path.join(COVERAGE_DIR, 'test.xml') 8 | 9 | 10 | def json_coverage(): 11 | COVERAGE_FILE = path.join(COVERAGE_DIR, 'coverage-final.json') 12 | COVERAGE_SUMMARY = path.join(COVERAGE_DIR, 'coverage-summary.json') 13 | 14 | coverage = Coverage(config_file=path.join(ROOT, 'pyproject.toml')) 15 | coverage.load() 16 | coverage.json_report(outfile=COVERAGE_FILE) 17 | 18 | report = json.load(open(COVERAGE_FILE)) 19 | totals = report.get('totals') 20 | summary = { 21 | 'lines': { 22 | 'total': 23 | totals['covered_lines'] + totals['missing_lines'], 24 | 'covered': 25 | totals['covered_lines'], 26 | 'pct': 27 | totals['covered_lines'] / 28 | (totals['covered_lines'] + totals['missing_lines']) * 100 29 | }, 30 | 'statements': { 31 | 'total': None, 32 | 'covered': None, 33 | 'pct': None, 34 | }, 35 | 'functions': { 36 | 'total': None, 37 | 'covered': None, 38 | 'pct': None, 39 | }, 40 | 'branches': { 41 | 'total': totals['num_branches'], 42 | 'covered': totals['covered_branches'], 43 | 'pct': totals['covered_branches'] / totals['num_branches'] * 100 44 | } 45 | } 46 | 47 | json.dump({'total': summary}, open(COVERAGE_SUMMARY, 'w')) 48 | 49 | 50 | if __name__ == "__main__": 51 | json_coverage() 52 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description-file = README.md 3 | 4 | [flake8] 5 | exclude = .git,__pycache__,docs/source/conf.py,old,build,dist,.cache,pip,pypoetry 6 | max-complexity = 10 7 | 8 | [yapf] 9 | based_on_style = pep8 10 | indent_width = 4 11 | use_tabs = False 12 | 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | from os import path, sep 3 | 4 | # read the contents of your README file 5 | this_directory = path.abspath(path.dirname(__file__)) 6 | 7 | with open(path.join(this_directory, 'README.md'), 'rb') as f: 8 | long_description = f.read().decode('utf-8') 9 | 10 | kwargs = {"dir": this_directory, "sep": sep} 11 | with open("{dir}{sep}logdna{sep}VERSION".format(**kwargs)) as f: 12 | version = f.read().strip('\n') 13 | 14 | setup( 15 | name='logdna', 16 | packages=['logdna'], 17 | package_data={'': ['VERSION']}, 18 | version=version, 19 | description='A Python Package for Sending Logs to LogDNA', 20 | author='LogDNA Inc.', 21 | author_email='help@logdna.com', 22 | license='MIT', 23 | url='https://github.com/logdna/python', 24 | download_url=('https://github.com/logdna/python/tarball/%s' % (version)), 25 | keywords=['logdna', 'logging', 'logs', 'python', 'logdna.com', 'logger'], 26 | install_requires=[ 27 | 'requests', 28 | ], 29 | classifiers=[ 30 | 'Topic :: System :: Logging', 31 | 'Programming Language :: Python :: 3', 32 | 'Programming Language :: Python :: 3.6', 33 | 'Programming Language :: Python :: 3.7', 34 | 'Programming Language :: Python :: 3.8', 35 | 'Programming Language :: Python :: 3.9' 36 | ], 37 | long_description=long_description, 38 | long_description_content_type='text/markdown', 39 | ) 40 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | from tap.tests.testcase import TestCase # NOQA 2 | -------------------------------------------------------------------------------- /tests/test_logger.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import unittest 3 | import requests 4 | import time 5 | import os 6 | 7 | from logdna import LogDNAHandler 8 | from concurrent.futures import ThreadPoolExecutor 9 | from logdna.configs import defaults 10 | from unittest import mock 11 | from unittest.mock import patch 12 | 13 | now = int(time.time()) 14 | expectedLines = [] 15 | LOGDNA_API_KEY = os.environ.get('LOGDNA_INGESTION_KEY') 16 | logger = logging.getLogger('logdna') 17 | logger.setLevel(logging.INFO) 18 | sample_args = { 19 | 'app': 'differentTest', 20 | 'level': 'debug', 21 | 'hostname': 'differentHost', 22 | 'env': 'differentEnv' 23 | } 24 | 25 | sample_record = logging.LogRecord('test', logging.INFO, 'test', 5, 26 | 'Something to test', [sample_args], '', '', 27 | '') 28 | sample_message = { 29 | 'line': 'Something to test', 30 | 'hostname': 'differentHost', 31 | 'level': 'debug', 32 | 'app': 'differentTest', 33 | 'env': 'differentEnv', 34 | 'meta': { 35 | 'args': sample_args, 36 | 'name': 'test', 37 | 'pathname': 'test', 38 | 'lineno': 5 39 | } 40 | } 41 | 42 | sample_options = { 43 | 'hostname': 'localhost', 44 | 'ip': '10.0.1.1', 45 | 'mac': 'C0:FF:EE:C0:FF:EE', 46 | 'tags': 'sample,test', 47 | 'index_meta': True, 48 | 'now': int(time.time() * 1000), 49 | 'retry_interval_secs': 0.5 50 | } 51 | 52 | 53 | class MockThreadPoolExecutor(): 54 | def __init__(self, **kwargs): 55 | pass 56 | 57 | def __enter__(self): 58 | return self 59 | 60 | def __exit__(self, exc_type, exc_value, exc_traceback): 61 | pass 62 | 63 | def submit(self, fn, *args, **kwargs): 64 | # execute functions in series without creating threads 65 | # for easier unit testing 66 | result = fn(*args, **kwargs) 67 | return result 68 | 69 | def shutdown(self, wait=True): 70 | pass 71 | 72 | 73 | class LogDNAHandlerTest(unittest.TestCase): 74 | def test_handler(self): 75 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 76 | self.assertIsInstance(handler, logging.Handler) 77 | self.assertIsInstance( 78 | handler.internal_handler, logging.StreamHandler) 79 | self.assertIsNotNone(handler.internalLogger) 80 | self.assertEqual(handler.key, LOGDNA_API_KEY) 81 | self.assertEqual(handler.hostname, sample_options['hostname']) 82 | self.assertEqual(handler.ip, sample_options['ip']) 83 | self.assertEqual(handler.mac, sample_options['mac']) 84 | self.assertEqual(handler.loglevel, 'info') 85 | self.assertEqual(handler.app, '') 86 | self.assertEqual(handler.env, '') 87 | self.assertEqual(handler.tags, sample_options['tags'].split(',')) 88 | self.assertEqual(handler.custom_fields, defaults['META_FIELDS']) 89 | 90 | # Set the Connection Variables 91 | self.assertEqual(handler.url, defaults['LOGDNA_URL']) 92 | self.assertEqual(handler.request_timeout, 93 | defaults['DEFAULT_REQUEST_TIMEOUT']) 94 | self.assertEqual(handler.user_agent, defaults['USER_AGENT']) 95 | self.assertEqual(handler.max_retry_attempts, 96 | defaults['MAX_RETRY_ATTEMPTS']) 97 | self.assertEqual(handler.max_retry_jitter, 98 | defaults['MAX_RETRY_JITTER']) 99 | self.assertEqual(handler.max_concurrent_requests, 100 | defaults['MAX_CONCURRENT_REQUESTS']) 101 | self.assertEqual(handler.retry_interval_secs, 102 | sample_options['retry_interval_secs']) 103 | 104 | # Set the Flush-related Variables 105 | self.assertEqual(handler.buf, []) 106 | self.assertEqual(handler.buf_size, 0) 107 | self.assertIsNone(handler.flusher) 108 | self.assertTrue(handler.index_meta) 109 | self.assertEqual(handler.flush_limit, defaults['FLUSH_LIMIT']) 110 | self.assertEqual(handler.flush_interval_secs, 111 | defaults['FLUSH_INTERVAL_SECS']) 112 | self.assertEqual(handler.buf_retention_limit, 113 | defaults['BUF_RETENTION_LIMIT']) 114 | 115 | # Set up the Thread Pools 116 | self.assertIsInstance( 117 | handler.worker_thread_pool, ThreadPoolExecutor) 118 | self.assertIsInstance( 119 | handler.request_thread_pool, ThreadPoolExecutor) 120 | self.assertEqual(handler.level, logging.DEBUG) 121 | 122 | @mock.patch('time.time', unittest.mock.MagicMock(return_value=now)) 123 | def test_flusher(self): 124 | with patch('requests.post') as post_mock: 125 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 126 | r = requests.Response() 127 | r.status_code = 200 128 | r.reason = 'OK' 129 | post_mock.return_value = r 130 | handler.emit(sample_record) 131 | self.assertIsNotNone(handler.flusher) 132 | handler.close_flusher() 133 | self.assertIsNone(handler.flusher) 134 | 135 | def test_emit(self): 136 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 137 | handler.buffer_log = unittest.mock.Mock() 138 | handler.emit(sample_record) 139 | sample_message['timestamp'] = unittest.mock.ANY 140 | handler.buffer_log.assert_called_once_with(sample_message) 141 | 142 | @mock.patch('time.time', unittest.mock.MagicMock(return_value=now)) 143 | def test_try_lock_and_do_flush_request(self): 144 | with patch('requests.post') as post_mock: 145 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 146 | r = requests.Response() 147 | r.status_code = 200 148 | r.reason = 'OK' 149 | post_mock.return_value = r 150 | sample_message['timestamp'] = unittest.mock.ANY 151 | handler.buf = [sample_message] 152 | test_buf = handler.buf.copy() 153 | handler.try_lock_and_do_flush_request() 154 | post_mock.assert_called_with( 155 | url=handler.url, 156 | json={ 157 | 'e': 'ls', 158 | 'ls': test_buf 159 | }, 160 | params={ 161 | 'hostname': handler.hostname, 162 | 'ip': handler.ip, 163 | 'mac': handler.mac, 164 | 'tags': handler.tags, 165 | 'now': int(now * 1000) 166 | }, 167 | stream=True, 168 | allow_redirects=True, 169 | timeout=handler.request_timeout, 170 | headers={ 171 | 'user-agent': handler.user_agent, 172 | 'apikey': LOGDNA_API_KEY}) 173 | self.assertTrue(post_mock.call_count, 1) 174 | 175 | @mock.patch('time.time', unittest.mock.MagicMock(return_value=now)) 176 | def test_try_request_500(self): 177 | with patch('requests.post') as post_mock: 178 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 179 | r = requests.Response() 180 | r.status_code = 500 181 | r.reason = 'Internal Server Error' 182 | post_mock.return_value = r 183 | sample_message['timestamp'] = unittest.mock.ANY 184 | handler.buf = [sample_message] 185 | handler.try_request([]) 186 | self.assertTrue(post_mock.call_count, 3) 187 | 188 | @mock.patch('time.time', unittest.mock.MagicMock(return_value=now)) 189 | def test_try_request_502(self): 190 | with patch('requests.post') as post_mock: 191 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 192 | r = requests.Response() 193 | r.status_code = 502 194 | r.reason = 'Bad Gateway' 195 | post_mock.return_value = r 196 | sample_message['timestamp'] = unittest.mock.ANY 197 | handler.buf = [sample_message] 198 | handler.try_request([]) 199 | self.assertTrue(post_mock.call_count, 3) 200 | 201 | @mock.patch('time.time', unittest.mock.MagicMock(return_value=now)) 202 | def test_try_request_504(self): 203 | with patch('requests.post') as post_mock: 204 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 205 | r = requests.Response() 206 | r.status_code = 504 207 | r.reason = 'Gateway Timeout' 208 | post_mock.return_value = r 209 | sample_message['timestamp'] = unittest.mock.ANY 210 | handler.buf = [sample_message] 211 | handler.try_request([]) 212 | self.assertTrue(post_mock.call_count, 3) 213 | 214 | @mock.patch('time.time', unittest.mock.MagicMock(return_value=now)) 215 | def test_try_request_429(self): 216 | with patch('requests.post') as post_mock: 217 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 218 | r = requests.Response() 219 | r.status_code = 429 220 | r.reason = 'Too Many Requests' 221 | post_mock.return_value = r 222 | sample_message['timestamp'] = unittest.mock.ANY 223 | handler.buf = [sample_message] 224 | handler.try_request([]) 225 | self.assertTrue(post_mock.call_count, 3) 226 | 227 | @mock.patch('time.time', unittest.mock.MagicMock(return_value=now)) 228 | def test_try_request_403(self): 229 | with patch('requests.post') as post_mock: 230 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 231 | r = requests.Response() 232 | r.status_code = 403 233 | r.reason = 'Forbidden' 234 | post_mock.return_value = r 235 | sample_message['timestamp'] = unittest.mock.ANY 236 | handler.buf = [sample_message] 237 | handler.try_request([]) 238 | self.assertTrue(post_mock.call_count, 1) 239 | 240 | @mock.patch('time.time', unittest.mock.MagicMock(return_value=now)) 241 | def test_try_request_403_log_response(self): 242 | with patch('requests.post') as post_mock: 243 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 244 | r = requests.Response() 245 | r.status_code = 403 246 | r.reason = 'Forbidden' 247 | post_mock.return_value = r 248 | sample_options['log_error_response'] = True 249 | sample_message['timestamp'] = unittest.mock.ANY 250 | handler.buf = [sample_message] 251 | handler.try_request([]) 252 | self.assertTrue(post_mock.call_count, 1) 253 | 254 | def test_close(self): 255 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 256 | close_flusher_mock = unittest.mock.Mock() 257 | close_flusher_mock.side_effect = handler.close_flusher 258 | handler.schedule_flush_sync = unittest.mock.Mock() 259 | handler.close_flusher = close_flusher_mock 260 | handler.close() 261 | handler.close_flusher.assert_called_once_with() 262 | handler.schedule_flush_sync.assert_called_once_with( 263 | should_block=True) 264 | self.assertIsNone(handler.worker_thread_pool) 265 | self.assertIsNone(handler.request_thread_pool) 266 | 267 | # These should be separate objects, since there is already 268 | # a variable in the base class named self.lock. We want 269 | # to make sure that a separate lock is created for the 270 | # locking semantics of the LogDNA Handler 271 | def test_lock_var_separate_from_local_lock_var(self): 272 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 273 | self.assertIsNotNone(handler) 274 | 275 | # Test that we did not replace the base class' instance var. 276 | self.assertIsNotNone(handler._lock) 277 | self.assertIsNotNone(handler.lock) 278 | self.assertNotEquals(handler.lock, handler._lock) 279 | 280 | def test_flush(self): 281 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 282 | handler.worker_thread_pool = MockThreadPoolExecutor() 283 | handler.request_thread_pool = MockThreadPoolExecutor() 284 | handler.buf = [sample_message] 285 | handler.buf_size += len(handler.buf) 286 | handler.try_request = unittest.mock.Mock() 287 | handler.flush() 288 | handler.try_request.assert_called_once_with([sample_message]) 289 | 290 | def test_buffer_log(self): 291 | with patch('requests.post') as post_mock: 292 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 293 | r = requests.Response() 294 | r.status_code = 200 295 | r.reason = 'OK' 296 | post_mock.return_value = r 297 | handler.worker_thread_pool = MockThreadPoolExecutor() 298 | handler.request_thread_pool = MockThreadPoolExecutor() 299 | handler.flush = unittest.mock.Mock() 300 | sample_message['timestamp'] = now 301 | handler.flush_limit = 0 302 | handler.buffer_log(sample_message) 303 | handler.flush.assert_called_once_with() 304 | self.assertEqual(handler.buf, [sample_message]) 305 | self.assertEqual(handler.buf_size, len(sample_message['line'])) 306 | 307 | # Attempts to reproduce the specific scenario that resulted in 308 | # https://mezmo.atlassian.net/browse/LOG-15414 where log messages 309 | # would be dropped due to race conditions. The test essentially 310 | # does the following: 311 | # 1. Create a LogDNAHandler 312 | # 2. Call handler.emit() with a large number of log records at a rate 313 | # sufficiently high to trigger the race 314 | # 3. Verify that no log records are dropped. 315 | # 316 | # This test is not deterministic, but it should be sufficient to 317 | # catch regressions. It reliably reproduces the issue in question 318 | # and fails with the previous version of this code. 319 | @mock.patch('time.time', unittest.mock.MagicMock(return_value=now)) 320 | def test_when_emitManyLogs_then_noLogsDropped(self): 321 | num_logs = 10**5 322 | received = list() 323 | 324 | def append_received(json=None, **kwargs): 325 | ids = [int(log['line']) for log in json['ls']] 326 | for id in ids: 327 | received.append(id) 328 | r = requests.Response() 329 | r.status_code = 200 330 | r.reason = 'OK' 331 | # Simulate some reasonable request latency 332 | time.sleep(0.1) 333 | return r 334 | 335 | def get_sample_record(id): 336 | return logging.LogRecord( 337 | name='test', 338 | level=logging.INFO, 339 | pathname='test', 340 | lineno=5, 341 | msg=str(id), 342 | args=[sample_args], 343 | exc_info='', 344 | func='', 345 | sinfo='') 346 | 347 | with patch('requests.post', side_effect=append_received): 348 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 349 | for i in range(num_logs): 350 | handler.emit(get_sample_record(i)) 351 | handler.close() 352 | 353 | self.assertEqual(len(received), num_logs) 354 | self.assertEqual(set(received), set(range(num_logs))) 355 | 356 | def test_when_handlerShutDown_then_handlerDoesNotHang(self): 357 | handler = LogDNAHandler(LOGDNA_API_KEY, sample_options) 358 | self.assertIsNotNone(handler) 359 | # Do nothing. This test should pass by virtue of not hanging. 360 | 361 | 362 | if __name__ == '__main__': 363 | unittest.main() 364 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import patch 3 | from logdna.utils import is_jsonable 4 | from logdna.utils import sanitize_meta 5 | from logdna.utils import get_ip 6 | from logdna.utils import normalize_list_option 7 | 8 | IP = '10.0.50.10' 9 | VIP = '10.1.60.20' 10 | 11 | 12 | class JSONTest(unittest.TestCase): 13 | def setUp(self): 14 | self.valid = {'key': 'value'} 15 | self.invalid = {'key': set()} 16 | 17 | def test_serialize_valid_json(self): 18 | self.assertTrue(is_jsonable(self.valid), 'json serializeble = True') 19 | 20 | def test_serialize_invalid_json(self): 21 | self.assertFalse(is_jsonable(self.invalid), 22 | 'non json serializable = False') 23 | 24 | 25 | class SanitizeTest(unittest.TestCase): 26 | def setUp(self): 27 | self.valid = {'foo': 'bar', 'baz': 'whizbang'} 28 | self.invalid = {'bar': 'foo', 'baz': set()} 29 | 30 | def test_sanitize_simple(self): 31 | clean = sanitize_meta(self.valid, True) 32 | self.assertDictEqual(clean, self.valid) 33 | 34 | def test_sanitize_complex(self): 35 | clean = sanitize_meta(self.invalid, True) 36 | self.assertDictEqual(clean, { 37 | 'bar': 'foo', 38 | '__errors': 'These keys have been sanitized: baz' 39 | }) 40 | 41 | 42 | class IPTest(unittest.TestCase): 43 | @patch('socket.socket', **{'return_value.connect.side_effect': OSError()}) 44 | def test_get_ip_socket_error(self, _): 45 | self.assertEqual(get_ip(), '127.0.0.1', 46 | 'default to localhost on error') 47 | 48 | @patch('socket.socket', 49 | **{'return_value.getsockname.return_value': [IP, VIP]}) 50 | def test_get_ip_default(self, _): 51 | self.assertEqual(get_ip(), IP, 'default to localhost on error') 52 | 53 | 54 | class NormalizeListOptionTest(unittest.TestCase): 55 | def test_normalize_simple(self): 56 | value1 = normalize_list_option({'tags': ' a, b'}, 'tags') 57 | value2 = normalize_list_option({'tags': ['a', 'b']}, 'tags') 58 | value3 = normalize_list_option({'tags': ('a', 'b')}, 'tags') 59 | self.assertEqual(value1, ['a', 'b']) 60 | self.assertEqual(value1, value2) 61 | self.assertEqual(value3, []) 62 | --------------------------------------------------------------------------------