kinto 23.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. kinto/__init__.py +92 -0
  2. kinto/__main__.py +249 -0
  3. kinto/authorization.py +134 -0
  4. kinto/config/__init__.py +94 -0
  5. kinto/config/kinto.tpl +270 -0
  6. kinto/contribute.json +27 -0
  7. kinto/core/__init__.py +246 -0
  8. kinto/core/authentication.py +48 -0
  9. kinto/core/authorization.py +311 -0
  10. kinto/core/cache/__init__.py +131 -0
  11. kinto/core/cache/memcached.py +112 -0
  12. kinto/core/cache/memory.py +104 -0
  13. kinto/core/cache/postgresql/__init__.py +178 -0
  14. kinto/core/cache/postgresql/schema.sql +23 -0
  15. kinto/core/cache/testing.py +208 -0
  16. kinto/core/cornice/__init__.py +93 -0
  17. kinto/core/cornice/cors.py +144 -0
  18. kinto/core/cornice/errors.py +40 -0
  19. kinto/core/cornice/pyramidhook.py +373 -0
  20. kinto/core/cornice/renderer.py +89 -0
  21. kinto/core/cornice/resource.py +205 -0
  22. kinto/core/cornice/service.py +641 -0
  23. kinto/core/cornice/util.py +138 -0
  24. kinto/core/cornice/validators/__init__.py +94 -0
  25. kinto/core/cornice/validators/_colander.py +142 -0
  26. kinto/core/cornice/validators/_marshmallow.py +182 -0
  27. kinto/core/cornice_swagger/__init__.py +92 -0
  28. kinto/core/cornice_swagger/converters/__init__.py +21 -0
  29. kinto/core/cornice_swagger/converters/exceptions.py +6 -0
  30. kinto/core/cornice_swagger/converters/parameters.py +90 -0
  31. kinto/core/cornice_swagger/converters/schema.py +249 -0
  32. kinto/core/cornice_swagger/swagger.py +725 -0
  33. kinto/core/cornice_swagger/templates/index.html +73 -0
  34. kinto/core/cornice_swagger/templates/index_script_template.html +21 -0
  35. kinto/core/cornice_swagger/util.py +42 -0
  36. kinto/core/cornice_swagger/views.py +78 -0
  37. kinto/core/decorators.py +74 -0
  38. kinto/core/errors.py +216 -0
  39. kinto/core/events.py +301 -0
  40. kinto/core/initialization.py +738 -0
  41. kinto/core/listeners/__init__.py +9 -0
  42. kinto/core/metrics.py +94 -0
  43. kinto/core/openapi.py +115 -0
  44. kinto/core/permission/__init__.py +202 -0
  45. kinto/core/permission/memory.py +167 -0
  46. kinto/core/permission/postgresql/__init__.py +489 -0
  47. kinto/core/permission/postgresql/migrations/migration_001_002.sql +18 -0
  48. kinto/core/permission/postgresql/schema.sql +41 -0
  49. kinto/core/permission/testing.py +487 -0
  50. kinto/core/resource/__init__.py +1311 -0
  51. kinto/core/resource/model.py +412 -0
  52. kinto/core/resource/schema.py +502 -0
  53. kinto/core/resource/viewset.py +230 -0
  54. kinto/core/schema.py +119 -0
  55. kinto/core/scripts.py +50 -0
  56. kinto/core/statsd.py +1 -0
  57. kinto/core/storage/__init__.py +436 -0
  58. kinto/core/storage/exceptions.py +53 -0
  59. kinto/core/storage/generators.py +58 -0
  60. kinto/core/storage/memory.py +651 -0
  61. kinto/core/storage/postgresql/__init__.py +1131 -0
  62. kinto/core/storage/postgresql/client.py +120 -0
  63. kinto/core/storage/postgresql/migrations/migration_001_002.sql +10 -0
  64. kinto/core/storage/postgresql/migrations/migration_002_003.sql +33 -0
  65. kinto/core/storage/postgresql/migrations/migration_003_004.sql +18 -0
  66. kinto/core/storage/postgresql/migrations/migration_004_005.sql +20 -0
  67. kinto/core/storage/postgresql/migrations/migration_005_006.sql +11 -0
  68. kinto/core/storage/postgresql/migrations/migration_006_007.sql +74 -0
  69. kinto/core/storage/postgresql/migrations/migration_007_008.sql +66 -0
  70. kinto/core/storage/postgresql/migrations/migration_008_009.sql +41 -0
  71. kinto/core/storage/postgresql/migrations/migration_009_010.sql +98 -0
  72. kinto/core/storage/postgresql/migrations/migration_010_011.sql +14 -0
  73. kinto/core/storage/postgresql/migrations/migration_011_012.sql +9 -0
  74. kinto/core/storage/postgresql/migrations/migration_012_013.sql +71 -0
  75. kinto/core/storage/postgresql/migrations/migration_013_014.sql +14 -0
  76. kinto/core/storage/postgresql/migrations/migration_014_015.sql +95 -0
  77. kinto/core/storage/postgresql/migrations/migration_015_016.sql +4 -0
  78. kinto/core/storage/postgresql/migrations/migration_016_017.sql +81 -0
  79. kinto/core/storage/postgresql/migrations/migration_017_018.sql +25 -0
  80. kinto/core/storage/postgresql/migrations/migration_018_019.sql +8 -0
  81. kinto/core/storage/postgresql/migrations/migration_019_020.sql +7 -0
  82. kinto/core/storage/postgresql/migrations/migration_020_021.sql +68 -0
  83. kinto/core/storage/postgresql/migrations/migration_021_022.sql +62 -0
  84. kinto/core/storage/postgresql/migrations/migration_022_023.sql +5 -0
  85. kinto/core/storage/postgresql/migrations/migration_023_024.sql +6 -0
  86. kinto/core/storage/postgresql/migrations/migration_024_025.sql +6 -0
  87. kinto/core/storage/postgresql/migrator.py +98 -0
  88. kinto/core/storage/postgresql/pool.py +55 -0
  89. kinto/core/storage/postgresql/schema.sql +143 -0
  90. kinto/core/storage/testing.py +1857 -0
  91. kinto/core/storage/utils.py +37 -0
  92. kinto/core/testing.py +182 -0
  93. kinto/core/utils.py +553 -0
  94. kinto/core/views/__init__.py +0 -0
  95. kinto/core/views/batch.py +163 -0
  96. kinto/core/views/errors.py +145 -0
  97. kinto/core/views/heartbeat.py +106 -0
  98. kinto/core/views/hello.py +69 -0
  99. kinto/core/views/openapi.py +35 -0
  100. kinto/core/views/version.py +50 -0
  101. kinto/events.py +3 -0
  102. kinto/plugins/__init__.py +0 -0
  103. kinto/plugins/accounts/__init__.py +94 -0
  104. kinto/plugins/accounts/authentication.py +63 -0
  105. kinto/plugins/accounts/scripts.py +61 -0
  106. kinto/plugins/accounts/utils.py +13 -0
  107. kinto/plugins/accounts/views.py +136 -0
  108. kinto/plugins/admin/README.md +3 -0
  109. kinto/plugins/admin/VERSION +1 -0
  110. kinto/plugins/admin/__init__.py +40 -0
  111. kinto/plugins/admin/build/VERSION +1 -0
  112. kinto/plugins/admin/build/assets/index-CYFwtKtL.css +6 -0
  113. kinto/plugins/admin/build/assets/index-DJ0m93zA.js +149 -0
  114. kinto/plugins/admin/build/assets/logo-VBRiKSPX.png +0 -0
  115. kinto/plugins/admin/build/index.html +18 -0
  116. kinto/plugins/admin/public/help.html +25 -0
  117. kinto/plugins/admin/views.py +42 -0
  118. kinto/plugins/default_bucket/__init__.py +191 -0
  119. kinto/plugins/flush.py +28 -0
  120. kinto/plugins/history/__init__.py +65 -0
  121. kinto/plugins/history/listener.py +181 -0
  122. kinto/plugins/history/views.py +66 -0
  123. kinto/plugins/openid/__init__.py +131 -0
  124. kinto/plugins/openid/utils.py +14 -0
  125. kinto/plugins/openid/views.py +193 -0
  126. kinto/plugins/prometheus.py +300 -0
  127. kinto/plugins/statsd.py +85 -0
  128. kinto/schema_validation.py +135 -0
  129. kinto/views/__init__.py +34 -0
  130. kinto/views/admin.py +195 -0
  131. kinto/views/buckets.py +45 -0
  132. kinto/views/collections.py +58 -0
  133. kinto/views/contribute.py +39 -0
  134. kinto/views/groups.py +90 -0
  135. kinto/views/permissions.py +235 -0
  136. kinto/views/records.py +133 -0
  137. kinto-23.2.1.dist-info/METADATA +232 -0
  138. kinto-23.2.1.dist-info/RECORD +142 -0
  139. kinto-23.2.1.dist-info/WHEEL +5 -0
  140. kinto-23.2.1.dist-info/entry_points.txt +5 -0
  141. kinto-23.2.1.dist-info/licenses/LICENSE +13 -0
  142. kinto-23.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,131 @@
1
+ import requests
2
+ from pyramid import authentication as base_auth
3
+ from pyramid.interfaces import IAuthenticationPolicy
4
+ from pyramid.settings import aslist
5
+ from zope.interface import implementer
6
+
7
+ from kinto.core import logger
8
+ from kinto.core import utils as core_utils
9
+ from kinto.core.openapi import OpenAPI
10
+
11
+ from .utils import fetch_openid_config
12
+
13
+
14
+ @implementer(IAuthenticationPolicy)
15
+ class OpenIDConnectPolicy(base_auth.CallbackAuthenticationPolicy):
16
+ def __init__(self, issuer, client_id, realm="Realm", **kwargs):
17
+ self.realm = realm
18
+ self.issuer = issuer
19
+ self.client_id = client_id
20
+ self.client_secret = kwargs.get("client_secret", "")
21
+ self.header_type = kwargs.get("header_type", "Bearer")
22
+ self.userid_field = kwargs.get("userid_field", "sub")
23
+ self.verification_ttl = int(kwargs.get("verification_ttl_seconds", 86400))
24
+
25
+ # Fetch OpenID config (at instantiation, ie. startup)
26
+ self.oid_config = fetch_openid_config(issuer)
27
+
28
+ self._jwt_keys = None
29
+
30
+ def unauthenticated_userid(self, request):
31
+ """Return the userid or ``None`` if token could not be verified."""
32
+ settings = request.registry.settings
33
+ hmac_secret = settings["userid_hmac_secret"]
34
+
35
+ authorization = request.headers.get("Authorization", "")
36
+ try:
37
+ authmeth, access_token = authorization.split(" ", 1)
38
+ except ValueError:
39
+ return None
40
+
41
+ if authmeth.lower() != self.header_type.lower():
42
+ return None
43
+
44
+ # XXX JWT Access token
45
+ # https://auth0.com/docs/tokens/access-token#access-token-format
46
+
47
+ # Check cache if these tokens were already verified.
48
+ hmac_tokens = core_utils.hmac_digest(hmac_secret, access_token)
49
+ cache_key = f"openid:verify:{hmac_tokens}"
50
+ payload = request.registry.cache.get(cache_key)
51
+ if payload is None:
52
+ # This can take some time.
53
+ payload = self._verify_token(access_token)
54
+ if payload is None:
55
+ return None
56
+ # Save for next time / refresh ttl.
57
+ request.registry.cache.set(cache_key, payload, ttl=self.verification_ttl)
58
+ request.bound_data["user_profile"] = payload
59
+ # Extract meaningful field from userinfo (eg. email or sub)
60
+ return payload.get(self.userid_field)
61
+
62
+ def forget(self, request):
63
+ """A no-op. Credentials are sent on every request.
64
+ Return WWW-Authenticate Realm header for Bearer token.
65
+ """
66
+ return [("WWW-Authenticate", '%s realm="%s"' % (self.header_type, self.realm))]
67
+
68
+ def _verify_token(self, access_token):
69
+ uri = self.oid_config["userinfo_endpoint"]
70
+ # Opaque access token string. Fetch user info from profile.
71
+ try:
72
+ resp = requests.get(uri, headers={"Authorization": "Bearer " + access_token})
73
+ resp.raise_for_status()
74
+ userprofile = resp.json()
75
+ return userprofile
76
+
77
+ except (requests.exceptions.HTTPError, ValueError, KeyError) as e:
78
+ logger.debug("Unable to fetch user profile from %s (%s)" % (uri, e))
79
+ return None
80
+
81
+
82
+ def get_user_profile(request):
83
+ return request.bound_data.get("user_profile", {})
84
+
85
+
86
+ def includeme(config):
87
+ # Activate end-points.
88
+ config.scan("kinto.plugins.openid.views")
89
+
90
+ settings = config.get_settings()
91
+
92
+ openid_policies = []
93
+ for policy in aslist(settings["multiauth.policies"]):
94
+ v = settings.get("multiauth.policy.%s.use" % policy, "")
95
+ if v.endswith("OpenIDConnectPolicy"):
96
+ openid_policies.append(policy)
97
+
98
+ if len(openid_policies) == 0:
99
+ # Do not add the capability if no policy is configured.
100
+ return
101
+
102
+ providers_infos = []
103
+ for name in openid_policies:
104
+ issuer = settings["multiauth.policy.%s.issuer" % name]
105
+ openid_config = fetch_openid_config(issuer)
106
+
107
+ client_id = settings["multiauth.policy.%s.client_id" % name]
108
+ header_type = settings.get("multiauth.policy.%s.header_type", "Bearer")
109
+
110
+ providers_infos.append(
111
+ {
112
+ "name": name,
113
+ "issuer": openid_config["issuer"],
114
+ "auth_path": "/openid/%s/login" % name,
115
+ "client_id": client_id,
116
+ "header_type": header_type,
117
+ "userinfo_endpoint": openid_config["userinfo_endpoint"],
118
+ }
119
+ )
120
+
121
+ OpenAPI.expose_authentication_method(
122
+ name, {"type": "oauth2", "authorizationUrl": openid_config["authorization_endpoint"]}
123
+ )
124
+
125
+ config.add_api_capability(
126
+ "openid",
127
+ description="OpenID connect support.",
128
+ url="http://kinto.readthedocs.io/en/stable/api/1.x/authentication.html",
129
+ providers=providers_infos,
130
+ )
131
+ config.add_request_method(get_user_profile, name="get_user_profile")
@@ -0,0 +1,14 @@
1
+ import requests
2
+
3
+
4
+ _configs = {}
5
+
6
+
7
+ def fetch_openid_config(issuer):
8
+ global _configs
9
+
10
+ if issuer not in _configs:
11
+ resp = requests.get(issuer.rstrip("/") + "/.well-known/openid-configuration")
12
+ _configs[issuer] = resp.json()
13
+
14
+ return _configs[issuer]
@@ -0,0 +1,193 @@
1
+ import base64
2
+ import urllib.parse
3
+
4
+ import colander
5
+ import requests
6
+ from pyramid import httpexceptions
7
+
8
+ from kinto.core import Service
9
+ from kinto.core.cornice.validators import colander_validator
10
+ from kinto.core.errors import ERRORS, raise_invalid
11
+ from kinto.core.resource.schema import ErrorResponseSchema
12
+ from kinto.core.schema import URL
13
+ from kinto.core.utils import random_bytes_hex
14
+
15
+ from .utils import fetch_openid_config
16
+
17
+
18
+ DEFAULT_STATE_TTL_SECONDS = 3600
19
+ DEFAULT_STATE_LENGTH = 32
20
+
21
+
22
+ class RedirectHeadersSchema(colander.MappingSchema):
23
+ """Redirect response headers."""
24
+
25
+ location = colander.SchemaNode(colander.String(), name="Location")
26
+
27
+
28
+ class RedirectResponseSchema(colander.MappingSchema):
29
+ """Redirect response schema."""
30
+
31
+ headers = RedirectHeadersSchema()
32
+
33
+
34
+ response_schemas = {
35
+ "307": RedirectResponseSchema(description="Successful redirection."),
36
+ "400": ErrorResponseSchema(description="The request is invalid."),
37
+ }
38
+
39
+
40
+ def provider_validator(request, **kwargs):
41
+ """
42
+ This validator verifies that the validator in URL (eg. /openid/auth0/login)
43
+ is a configured OpenIDConnect policy.
44
+ """
45
+ provider = request.matchdict["provider"]
46
+ used = request.registry.settings.get("multiauth.policy.%s.use" % provider, "")
47
+ if not used.endswith("OpenIDConnectPolicy"):
48
+ request.errors.add("path", "provider", "Unknow provider %r" % provider)
49
+
50
+
51
+ class LoginQuerystringSchema(colander.MappingSchema):
52
+ """
53
+ Querystring schema for the login endpoint.
54
+ """
55
+
56
+ callback = URL()
57
+ scope = colander.SchemaNode(colander.String())
58
+ prompt = colander.SchemaNode(
59
+ colander.String(), validator=colander.Regex("none"), missing=colander.drop
60
+ )
61
+
62
+
63
+ class LoginSchema(colander.MappingSchema):
64
+ querystring = LoginQuerystringSchema()
65
+
66
+
67
+ login = Service(
68
+ name="openid_login", path="/openid/{provider}/login", description="Initiate the OAuth2 login"
69
+ )
70
+
71
+
72
+ @login.get(
73
+ schema=LoginSchema(),
74
+ validators=(colander_validator, provider_validator),
75
+ response_schemas=response_schemas,
76
+ )
77
+ def get_login(request):
78
+ """Initiates to login dance for the specified scopes and callback URI
79
+ using appropriate redirections."""
80
+
81
+ # Settings.
82
+ provider = request.matchdict["provider"]
83
+ settings_prefix = "multiauth.policy.%s." % provider
84
+ issuer = request.registry.settings[settings_prefix + "issuer"]
85
+ client_id = request.registry.settings[settings_prefix + "client_id"]
86
+ userid_field = request.registry.settings.get(settings_prefix + "userid_field")
87
+ state_ttl = int(
88
+ request.registry.settings.get(
89
+ settings_prefix + "state_ttl_seconds", DEFAULT_STATE_TTL_SECONDS
90
+ )
91
+ )
92
+ state_length = int(
93
+ request.registry.settings.get(settings_prefix + "state_length", DEFAULT_STATE_LENGTH)
94
+ )
95
+
96
+ # Read OpenID configuration (cached by issuer)
97
+ oid_config = fetch_openid_config(issuer)
98
+ auth_endpoint = oid_config["authorization_endpoint"]
99
+
100
+ scope = request.GET["scope"]
101
+ callback = request.GET["callback"]
102
+ prompt = request.GET.get("prompt")
103
+
104
+ # Check that email scope is requested if userid field is configured as email.
105
+ if userid_field == "email" and "email" not in scope:
106
+ error_details = {
107
+ "name": "scope",
108
+ "description": "Provider %s requires 'email' scope" % provider,
109
+ }
110
+ raise_invalid(request, **error_details)
111
+
112
+ # Generate a random string as state.
113
+ # And save it until code is traded.
114
+ state = random_bytes_hex(state_length)
115
+ request.registry.cache.set("openid:state:" + state, callback, ttl=state_ttl)
116
+
117
+ # Redirect the client to the Identity Provider that will eventually redirect
118
+ # to the OpenID token endpoint.
119
+ token_uri = request.route_url("openid_token", provider=provider)
120
+ params = dict(
121
+ client_id=client_id, response_type="code", scope=scope, redirect_uri=token_uri, state=state
122
+ )
123
+ if prompt:
124
+ # The 'prompt' parameter is optional.
125
+ params["prompt"] = prompt
126
+ redirect = f"{auth_endpoint}?{urllib.parse.urlencode(params)}"
127
+ raise httpexceptions.HTTPTemporaryRedirect(redirect)
128
+
129
+
130
+ class TokenQuerystringSchema(colander.MappingSchema):
131
+ """
132
+ Querystring schema for the token endpoint.
133
+ """
134
+
135
+ code = colander.SchemaNode(colander.String())
136
+ state = colander.SchemaNode(colander.String())
137
+
138
+
139
+ class TokenSchema(colander.MappingSchema):
140
+ querystring = TokenQuerystringSchema()
141
+
142
+
143
+ token = Service(name="openid_token", path="/openid/{provider}/token", description="")
144
+
145
+
146
+ @token.get(schema=TokenSchema(), validators=(colander_validator, provider_validator))
147
+ def get_token(request):
148
+ """Trades the specified code and state against access and ID tokens.
149
+ The client is redirected to the original ``callback`` URI with the
150
+ result in querystring."""
151
+
152
+ # Settings.
153
+ provider = request.matchdict["provider"]
154
+ settings_prefix = "multiauth.policy.%s." % provider
155
+ issuer = request.registry.settings[settings_prefix + "issuer"]
156
+ client_id = request.registry.settings[settings_prefix + "client_id"]
157
+ client_secret = request.registry.settings[settings_prefix + "client_secret"]
158
+
159
+ # Read OpenID configuration (cached by issuer)
160
+ oid_config = fetch_openid_config(issuer)
161
+ token_endpoint = oid_config["token_endpoint"]
162
+
163
+ code = request.GET["code"]
164
+ state = request.GET["state"]
165
+
166
+ # State can be used only once.
167
+ callback = request.registry.cache.delete("openid:state:" + state)
168
+ if callback is None:
169
+ error_details = {
170
+ "name": "state",
171
+ "description": "Invalid state",
172
+ "errno": ERRORS.INVALID_AUTH_TOKEN.value,
173
+ }
174
+ raise_invalid(request, **error_details)
175
+
176
+ # Trade the code for tokens on the Identity Provider.
177
+ # Google Identity requires to specify again redirect_uri.
178
+ redirect_uri = request.route_url("openid_token", provider=provider)
179
+ data = {
180
+ "code": code,
181
+ "client_id": client_id,
182
+ "client_secret": client_secret,
183
+ "redirect_uri": redirect_uri,
184
+ "grant_type": "authorization_code",
185
+ }
186
+ resp = requests.post(token_endpoint, data=data)
187
+
188
+ # The IdP response is forwarded to the client in the querystring/location hash.
189
+ # (eg. callback=`http://localhost:3000/#tokens=`)
190
+ token_info = resp.text.encode("utf-8")
191
+ encoded_token = base64.b64encode(token_info)
192
+ redirect = callback + urllib.parse.quote(encoded_token.decode("utf-8"))
193
+ raise httpexceptions.HTTPTemporaryRedirect(redirect)
@@ -0,0 +1,300 @@
1
+ import logging
2
+ import os
3
+ import shutil
4
+ import warnings
5
+ from time import perf_counter as time_now
6
+
7
+ from pyramid.exceptions import ConfigurationError
8
+ from pyramid.response import Response
9
+ from pyramid.settings import asbool, aslist
10
+ from zope.interface import implementer
11
+
12
+ from kinto.core import metrics
13
+ from kinto.core.utils import safe_wraps
14
+
15
+
16
+ try:
17
+ import prometheus_client as prometheus_module
18
+ except ImportError: # pragma: no cover
19
+ prometheus_module = None
20
+
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ _METRICS = {}
25
+ _REGISTRY = None
26
+
27
+
28
+ PROMETHEUS_MULTIPROC_DIR = os.getenv("PROMETHEUS_MULTIPROC_DIR")
29
+
30
+
31
+ def get_registry():
32
+ global _REGISTRY
33
+
34
+ if _REGISTRY is None:
35
+ if PROMETHEUS_MULTIPROC_DIR: # pragma: no cover
36
+ from prometheus_client import multiprocess
37
+
38
+ _reset_multiproc_folder_content()
39
+ # Ref: https://prometheus.github.io/client_python/multiprocess/
40
+ _REGISTRY = prometheus_module.CollectorRegistry()
41
+ multiprocess.MultiProcessCollector(_REGISTRY)
42
+ else:
43
+ _REGISTRY = prometheus_module.REGISTRY
44
+ logger.warning("Prometheus metrics will run in single-process mode only.")
45
+ return _REGISTRY
46
+
47
+
48
+ def _fix_metric_name(s):
49
+ return s.replace("-", "_").replace(".", "_").replace(" ", "_")
50
+
51
+
52
+ class Timer:
53
+ """
54
+ A decorator to time the execution of a function. It will use the
55
+ `prometheus_client.Histogram` to record the time taken by the function
56
+ in seconds. The histogram is passed as an argument to the
57
+ constructor.
58
+
59
+ Main limitation: it does not support `labels` on the decorator.
60
+ """
61
+
62
+ def __init__(self, histogram):
63
+ self.histogram = histogram
64
+ self._start_time = None
65
+
66
+ def set_labels(self, labels):
67
+ if not labels:
68
+ return
69
+ self.histogram = self.histogram.labels(*(label_value for _, label_value in labels))
70
+
71
+ def observe(self, value):
72
+ return self.histogram.observe(value)
73
+
74
+ def __call__(self, f):
75
+ @safe_wraps(f)
76
+ def _wrapped(*args, **kwargs):
77
+ start_time = time_now()
78
+ try:
79
+ return f(*args, **kwargs)
80
+ finally:
81
+ dt_sec = time_now() - start_time
82
+ self.histogram.observe(dt_sec)
83
+
84
+ return _wrapped
85
+
86
+ def __enter__(self):
87
+ return self.start()
88
+
89
+ def __exit__(self, typ, value, tb):
90
+ self.stop()
91
+
92
+ def start(self):
93
+ self._start_time = time_now()
94
+ return self
95
+
96
+ def stop(self):
97
+ if self._start_time is None: # pragma: nocover
98
+ raise RuntimeError("Timer has not started.")
99
+ dt_sec = time_now() - self._start_time
100
+ self.histogram.observe(dt_sec)
101
+ return self
102
+
103
+
104
+ class NoOpHistogram: # pragma: no cover
105
+ def observe(self, value):
106
+ pass
107
+
108
+ def labels(self, *args):
109
+ return self
110
+
111
+
112
+ @implementer(metrics.IMetricsService)
113
+ class PrometheusService:
114
+ def __init__(self, prefix="", disabled_metrics=[], histogram_buckets=None):
115
+ prefix_clean = ""
116
+ if prefix:
117
+ # In GCP Console, the metrics are grouped by the first
118
+ # word before the first underscore. Here we make sure the specified
119
+ # prefix is not mixed up with metrics names.
120
+ # (eg. `remote-settings` -> `remotesettings_`, `kinto_` -> `kinto_`)
121
+ prefix_clean = _fix_metric_name(prefix).replace("_", "") + "_"
122
+ self.prefix = prefix_clean.lower()
123
+ self.disabled_metrics = [m.replace(self.prefix, "") for m in disabled_metrics]
124
+ self.histogram_buckets = histogram_buckets
125
+
126
+ def timer(self, key, value=None, labels=[]):
127
+ global _METRICS
128
+
129
+ key = _fix_metric_name(key)
130
+ if key in self.disabled_metrics:
131
+ return Timer(histogram=NoOpHistogram())
132
+
133
+ key = self.prefix + key
134
+ if key not in _METRICS:
135
+ _METRICS[key] = prometheus_module.Histogram(
136
+ key,
137
+ f"Histogram of {key}",
138
+ labelnames=[label_name for label_name, _ in labels],
139
+ buckets=self.histogram_buckets,
140
+ )
141
+
142
+ if not isinstance(_METRICS[key], prometheus_module.Histogram):
143
+ raise RuntimeError(
144
+ f"Metric {key} already exists with different type ({_METRICS[key]})"
145
+ )
146
+
147
+ timer = Timer(histogram=_METRICS[key])
148
+ timer.set_labels(labels)
149
+
150
+ if value is not None:
151
+ # We are timing something.
152
+ return timer.observe(value)
153
+
154
+ # We are not timing anything, just returning the timer object
155
+ # (eg. to be used as decorator or context manager).
156
+ # Note that in this case, the labels values will be the same for all calls.
157
+ return timer
158
+
159
+ def observe(self, key, value, labels=[]):
160
+ global _METRICS
161
+
162
+ key = _fix_metric_name(key)
163
+ if key in self.disabled_metrics:
164
+ return
165
+
166
+ key = self.prefix + key
167
+ if key not in _METRICS:
168
+ _METRICS[key] = prometheus_module.Summary(
169
+ key,
170
+ f"Summary of {key}",
171
+ labelnames=[label_name for label_name, _ in labels],
172
+ )
173
+
174
+ if not isinstance(_METRICS[key], prometheus_module.Summary):
175
+ raise RuntimeError(
176
+ f"Metric {key} already exists with different type ({_METRICS[key]})"
177
+ )
178
+
179
+ m = _METRICS[key]
180
+ if labels:
181
+ m = m.labels(*(label_value for _, label_value in labels))
182
+
183
+ m.observe(value)
184
+
185
+ def count(self, key, count=1, unique=None):
186
+ global _METRICS
187
+
188
+ key = _fix_metric_name(key)
189
+ if key in self.disabled_metrics:
190
+ return
191
+
192
+ labels = []
193
+ if unique:
194
+ if isinstance(unique, str):
195
+ warnings.warn(
196
+ "`unique` parameter should be of type ``list[tuple[str, str]]``",
197
+ DeprecationWarning,
198
+ )
199
+ # Turn `unique` into a group and a value:
200
+ # "bob" -> "group.bob"
201
+ # "method.basicauth.mat" -> [("method_basicauth", "mat")]`
202
+ if "." not in unique:
203
+ unique = f"group.{unique}"
204
+ label_name, label_value = unique.rsplit(".", 1)
205
+ unique = [(label_name, label_value)]
206
+
207
+ labels = [
208
+ (_fix_metric_name(label_name), label_value) for label_name, label_value in unique
209
+ ]
210
+
211
+ key = self.prefix + key
212
+ if key not in _METRICS:
213
+ _METRICS[key] = prometheus_module.Counter(
214
+ key,
215
+ f"Counter of {key}",
216
+ labelnames=[label_name for label_name, _ in labels],
217
+ )
218
+
219
+ if not isinstance(_METRICS[key], prometheus_module.Counter):
220
+ raise RuntimeError(
221
+ f"Metric {key} already exists with different type ({_METRICS[key]})"
222
+ )
223
+
224
+ m = _METRICS[key]
225
+ if labels:
226
+ m = m.labels(*(label_value for _, label_value in labels))
227
+
228
+ m.inc(count)
229
+
230
+
231
+ def metrics_view(request):
232
+ registry = get_registry()
233
+ data = prometheus_module.generate_latest(registry)
234
+ resp = Response(body=data)
235
+ resp.headers["Content-Type"] = prometheus_module.CONTENT_TYPE_LATEST
236
+ resp.headers["Content-Length"] = str(len(data))
237
+ return resp
238
+
239
+
240
+ def _reset_multiproc_folder_content(): # pragma: no cover
241
+ shutil.rmtree(PROMETHEUS_MULTIPROC_DIR, ignore_errors=True)
242
+ os.makedirs(PROMETHEUS_MULTIPROC_DIR, exist_ok=True)
243
+
244
+
245
+ def reset_registry():
246
+ # This is mainly useful in tests, where the plugin is included
247
+ # several times with different settings.
248
+ registry = get_registry()
249
+
250
+ for collector in _METRICS.values():
251
+ try:
252
+ registry.unregister(collector)
253
+ except KeyError: # pragma: no cover
254
+ pass
255
+ _METRICS.clear()
256
+
257
+
258
+ def includeme(config):
259
+ if prometheus_module is None:
260
+ error_msg = (
261
+ "Please install Kinto with monitoring dependencies (e.g. prometheus-client package)"
262
+ )
263
+ raise ConfigurationError(error_msg)
264
+
265
+ settings = config.get_settings()
266
+
267
+ if not asbool(settings.get("prometheus_created_metrics_enabled", True)):
268
+ prometheus_module.disable_created_metrics()
269
+
270
+ prefix = settings.get("prometheus_prefix", settings["project_name"])
271
+ disabled_metrics = aslist(settings.get("prometheus_disabled_metrics", ""))
272
+
273
+ # Default buckets for histogram metrics are (.005, .01, .025, .05, .075, .1, .25, .5, .75, 1.0, 2.5, 5.0, 7.5, 10.0, INF)
274
+ # we reduce it from 15 to 8 values by default here, and let the user override it if needed.
275
+ histogram_buckets_values = aslist(
276
+ settings.get(
277
+ "prometheus_histogram_buckets", "0.01 0.05 0.1 0.5 1.0 3.0 6.0 Inf"
278
+ ) # Note: Inf is added by default.
279
+ )
280
+ histogram_buckets = [float(x) for x in histogram_buckets_values]
281
+ # Note: we don't need to check for INF or list size, it's done in the prometheus_client library.
282
+
283
+ get_registry() # Initialize the registry.
284
+
285
+ metrics_impl = PrometheusService(
286
+ prefix=prefix, disabled_metrics=disabled_metrics, histogram_buckets=histogram_buckets
287
+ )
288
+
289
+ config.add_api_capability(
290
+ "prometheus",
291
+ description="Prometheus metrics.",
292
+ url="https://github.com/Kinto/kinto/",
293
+ prefix=metrics_impl.prefix,
294
+ disabled_metrics=disabled_metrics,
295
+ )
296
+
297
+ config.add_route("prometheus_metrics", "/__metrics__")
298
+ config.add_view(metrics_view, route_name="prometheus_metrics")
299
+
300
+ config.registry.registerUtility(metrics_impl, metrics.IMetricsService)