├── .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 | [](#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 |
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 |
--------------------------------------------------------------------------------