plain.observer 0.12.0__py3-none-any.whl → 0.13.0__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.

Potentially problematic release.


This version of plain.observer might be problematic. Click here for more details.

@@ -1,5 +1,21 @@
1
1
  # plain-observer changelog
2
2
 
3
+ ## [0.13.0](https://github.com/dropseed/plain/releases/plain-observer@0.13.0) (2025-10-29)
4
+
5
+ ### What's changed
6
+
7
+ - Inline JavaScript and CSS extracted to separate asset files for Content Security Policy (CSP) compatibility ([784f3dd](https://github.com/dropseed/plain/commit/784f3dd972))
8
+ - Added CSP nonce support to inline scripts for improved security ([784f3dd](https://github.com/dropseed/plain/commit/784f3dd972))
9
+ - Added comprehensive CSP configuration documentation in README, including required `frame-ancestors 'self'` directive for toolbar panel ([784f3dd](https://github.com/dropseed/plain/commit/784f3dd972))
10
+ - Span and log indentation now uses CSS classes with data attributes instead of inline styles ([784f3dd](https://github.com/dropseed/plain/commit/784f3dd972))
11
+ - Timeline bar positioning now uses CSS custom properties set via JavaScript instead of inline styles ([784f3dd](https://github.com/dropseed/plain/commit/784f3dd972))
12
+ - Copy share URL button now uses data attributes and event delegation instead of inline onclick handlers ([784f3dd](https://github.com/dropseed/plain/commit/784f3dd972))
13
+ - Toolbar iframe now uses HTML attributes instead of inline styles ([784f3dd](https://github.com/dropseed/plain/commit/784f3dd972))
14
+
15
+ ### Upgrade instructions
16
+
17
+ - No changes required
18
+
3
19
  ## [0.12.0](https://github.com/dropseed/plain/releases/plain-observer@0.12.0) (2025-10-24)
4
20
 
5
21
  ### What's changed
plain/observer/README.md CHANGED
@@ -3,6 +3,7 @@
3
3
  **On-page telemetry and observability tools for Plain.**
4
4
 
5
5
  - [Installation](#installation)
6
+ - [Content Security Policy (CSP)](#content-security-policy-csp)
6
7
 
7
8
  ## Installation
8
9
 
@@ -44,3 +45,25 @@ plain migrate
44
45
  ```
45
46
 
46
47
  After installation, Observer will automatically integrate with your application's toolbar (if using `plain.admin`). You can access the web interface at `/observer/traces/` or use the CLI commands to analyze traces.
48
+
49
+ ## Content Security Policy (CSP)
50
+
51
+ If you're using a Content Security Policy (CSP), the Observer toolbar panel requires `frame-ancestors 'self'` to display trace information in an iframe.
52
+
53
+ Without this directive, the toolbar panel will fail to load with a CSP error: `"Refused to frame... because an ancestor violates the following Content Security Policy directive: 'frame-ancestors 'none'"`.
54
+
55
+ Example CSP configuration:
56
+
57
+ ```python
58
+ def DEFAULT_RESPONSE_HEADERS(request):
59
+ nonce = request.csp_nonce
60
+ return {
61
+ "Content-Security-Policy": (
62
+ f"default-src 'self'; "
63
+ f"script-src 'self' 'nonce-{nonce}'; "
64
+ f"style-src 'self' 'nonce-{nonce}'; "
65
+ f"frame-ancestors 'self'; " # Required for Observer toolbar
66
+ # ... other directives
67
+ ),
68
+ }
69
+ ```
@@ -0,0 +1,72 @@
1
+ /* Custom details arrow animation */
2
+ details[open] summary svg {
3
+ transform: rotate(90deg);
4
+ }
5
+
6
+ /* Apply padding based on span level - generated for levels 0-19 */
7
+ [data-span-level="0"] {
8
+ padding-left: 0rem;
9
+ }
10
+ [data-span-level="1"] {
11
+ padding-left: 1rem;
12
+ }
13
+ [data-span-level="2"] {
14
+ padding-left: 2rem;
15
+ }
16
+ [data-span-level="3"] {
17
+ padding-left: 3rem;
18
+ }
19
+ [data-span-level="4"] {
20
+ padding-left: 4rem;
21
+ }
22
+ [data-span-level="5"] {
23
+ padding-left: 5rem;
24
+ }
25
+ [data-span-level="6"] {
26
+ padding-left: 6rem;
27
+ }
28
+ [data-span-level="7"] {
29
+ padding-left: 7rem;
30
+ }
31
+ [data-span-level="8"] {
32
+ padding-left: 8rem;
33
+ }
34
+ [data-span-level="9"] {
35
+ padding-left: 9rem;
36
+ }
37
+ [data-span-level="10"] {
38
+ padding-left: 10rem;
39
+ }
40
+ [data-span-level="11"] {
41
+ padding-left: 11rem;
42
+ }
43
+ [data-span-level="12"] {
44
+ padding-left: 12rem;
45
+ }
46
+ [data-span-level="13"] {
47
+ padding-left: 13rem;
48
+ }
49
+ [data-span-level="14"] {
50
+ padding-left: 14rem;
51
+ }
52
+ [data-span-level="15"] {
53
+ padding-left: 15rem;
54
+ }
55
+ [data-span-level="16"] {
56
+ padding-left: 16rem;
57
+ }
58
+ [data-span-level="17"] {
59
+ padding-left: 17rem;
60
+ }
61
+ [data-span-level="18"] {
62
+ padding-left: 18rem;
63
+ }
64
+ [data-span-level="19"] {
65
+ padding-left: 19rem;
66
+ }
67
+
68
+ /* Apply positioning for timeline bars using CSS custom properties */
69
+ [data-span-id] {
70
+ left: var(--start, 0%);
71
+ width: var(--width, 100%);
72
+ }
@@ -0,0 +1,69 @@
1
+ // Observer JS - CSP-compliant timeline positioning and utilities
2
+
3
+ // Apply CSS custom properties for timeline positioning
4
+ function applyTimelinePositioning() {
5
+ document
6
+ .querySelectorAll("[data-start-percent][data-width-percent]")
7
+ .forEach((el) => {
8
+ const start = el.dataset.startPercent;
9
+ const width = el.dataset.widthPercent;
10
+ el.style.setProperty("--start", `${start}%`);
11
+ el.style.setProperty("--width", `${width}%`);
12
+ });
13
+ }
14
+
15
+ // Apply on page load
16
+ document.addEventListener("DOMContentLoaded", applyTimelinePositioning);
17
+
18
+ // Re-apply after htmx swaps content
19
+ document.addEventListener("htmx:afterSwap", applyTimelinePositioning);
20
+
21
+ // Copy share URL to clipboard with visual feedback
22
+ async function copyShareUrl(button, traceId) {
23
+ try {
24
+ const shareUrl = button.getAttribute("data-share-url");
25
+
26
+ // Copy to clipboard
27
+ await navigator.clipboard.writeText(shareUrl);
28
+
29
+ // Show success feedback on button
30
+ const originalHTML = button.innerHTML;
31
+ button.innerHTML =
32
+ '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>';
33
+ button.classList.remove("bg-emerald-700", "hover:bg-emerald-600");
34
+ button.classList.add("bg-green-600", "hover:bg-green-700");
35
+
36
+ // Also flash the URL text
37
+ const urlSpan = document.getElementById(`share-url-${traceId}`);
38
+ if (urlSpan) {
39
+ urlSpan.classList.add("text-green-400", "font-bold");
40
+ setTimeout(() => {
41
+ urlSpan.classList.remove("text-green-400", "font-bold");
42
+ }, 2000);
43
+ }
44
+
45
+ setTimeout(() => {
46
+ button.innerHTML = originalHTML;
47
+ button.classList.remove("bg-green-600", "hover:bg-green-700");
48
+ button.classList.add("bg-emerald-700", "hover:bg-emerald-600");
49
+ }, 2000);
50
+ } catch (error) {
51
+ console.error("Failed to copy share URL:", error);
52
+ alert("Failed to copy share URL. See console for details.");
53
+ }
54
+ }
55
+
56
+ // Set up event delegation for copy share URL buttons
57
+ function setupCopyShareUrlHandlers() {
58
+ document.addEventListener("click", (event) => {
59
+ const button = event.target.closest("[data-copy-share-url]");
60
+ if (button) {
61
+ event.preventDefault();
62
+ const traceId = button.getAttribute("data-trace-id");
63
+ copyShareUrl(button, traceId);
64
+ }
65
+ });
66
+ }
67
+
68
+ // Initialize on page load
69
+ document.addEventListener("DOMContentLoaded", setupCopyShareUrlHandlers);
@@ -1,4 +1,4 @@
1
- <div style="padding-left: {{ event.span_level * 1 }}rem;" class="border-l border-white/10">
1
+ <div data-span-level="{{ event.span_level }}" class="border-l border-white/10">
2
2
  <div class="ml-px px-2 py-1 text-xs flex items-start space-x-2">
3
3
  <div class="w-4 h-4 mr-2 flex items-center justify-center">
4
4
  <svg class="w-3 h-3 transform transition-transform" fill="currentColor" viewBox="0 0 20 20">
@@ -2,7 +2,7 @@
2
2
  {% set start_percent = (span_start_offset / trace.duration_ms() * 100) if trace.duration_ms() > 0 else 0 %}
3
3
  {% set width_percent = (span.duration_ms() / trace.duration_ms() * 100) if trace.duration_ms() > 0 else 0 %}
4
4
 
5
- <div style="padding-left: {{ event.span_level * 1 }}rem;" class="border-l border-white/20">
5
+ <div data-span-level="{{ event.span_level }}" class="border-l border-white/20">
6
6
  <details class="rounded bg-white/5 min-w-[600px] ml-px">
7
7
  <summary class="cursor-pointer hover:bg-white/10 transition-colors px-2 py-1 list-none [&::-webkit-details-marker]:hidden">
8
8
  <div class="flex items-center">
@@ -48,12 +48,16 @@
48
48
  data-[kind='INTERNAL']:bg-gray-500
49
49
  bg-white/30"
50
50
  data-kind="{{ span.kind }}"
51
- style="left: {{ start_percent }}%; width: {{ width_percent }}%;"
51
+ data-span-id="{{ span.span_id }}"
52
+ data-start-percent="{{ start_percent }}"
53
+ data-width-percent="{{ width_percent }}"
52
54
  title="{{ span.name }} - {{ span.duration_ms() }}ms">
53
55
  </div>
54
56
  <div
55
57
  class="absolute inset-0 flex items-center justify-start pl-1 text-xs text-white/80 font-medium whitespace-nowrap pointer-events-none"
56
- style="left: {{ start_percent }}%; width: {{ width_percent }}%;">
58
+ data-span-id="{{ span.span_id }}-label"
59
+ data-start-percent="{{ start_percent }}"
60
+ data-width-percent="{{ width_percent }}">
57
61
  {{ "%.2f"|format(span.duration_ms()) }}ms
58
62
  </div>
59
63
  </div>
@@ -4,10 +4,11 @@
4
4
  <div class="mb-2">
5
5
  <div class="flex items-center gap-2 bg-emerald-900/20 border border-emerald-700/50 rounded px-2 py-1">
6
6
  <span class="text-xs text-emerald-400 font-mono flex-1 truncate" id="share-url-{{ trace.id }}">
7
- {{ request.get_host() }}{{ url('observer:trace_shared', trace.share_id) }}
7
+ {{ request.host }}{{ url('observer:trace_shared', trace.share_id) }}
8
8
  </span>
9
9
  <button
10
- onclick="copyShareUrl(this, '{{ trace.id }}')"
10
+ data-copy-share-url
11
+ data-trace-id="{{ trace.id }}"
11
12
  data-share-url="{{ request.build_absolute_uri(url('observer:trace_shared', trace.share_id)) }}"
12
13
  class="px-2 py-0.5 text-xs bg-emerald-700 text-emerald-100 hover:bg-emerald-600 rounded transition-colors flex-shrink-0"
13
14
  title="Copy shareable URL">
@@ -148,46 +149,3 @@
148
149
  {% endfor %}
149
150
  </div>
150
151
  </div>
151
-
152
-
153
- <style>
154
- /* Custom details arrow animation */
155
- details[open] summary svg {
156
- transform: rotate(90deg);
157
- }
158
- </style>
159
-
160
- <script>
161
- async function copyShareUrl(button, traceId) {
162
- try {
163
- const shareUrl = button.getAttribute('data-share-url');
164
-
165
- // Copy to clipboard
166
- await navigator.clipboard.writeText(shareUrl);
167
-
168
- // Show success feedback on button
169
- const originalHTML = button.innerHTML;
170
- button.innerHTML = '<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 16 16"><path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z"/></svg>';
171
- button.classList.remove('bg-emerald-700', 'hover:bg-emerald-600');
172
- button.classList.add('bg-green-600', 'hover:bg-green-700');
173
-
174
- // Also flash the URL text
175
- const urlSpan = document.getElementById(`share-url-${traceId}`);
176
- if (urlSpan) {
177
- urlSpan.classList.add('text-green-400', 'font-bold');
178
- setTimeout(() => {
179
- urlSpan.classList.remove('text-green-400', 'font-bold');
180
- }, 2000);
181
- }
182
-
183
- setTimeout(() => {
184
- button.innerHTML = originalHTML;
185
- button.classList.remove('bg-green-600', 'hover:bg-green-700');
186
- button.classList.add('bg-emerald-700', 'hover:bg-emerald-600');
187
- }, 2000);
188
- } catch (error) {
189
- console.error('Failed to copy share URL:', error);
190
- alert('Failed to copy share URL. See console for details.');
191
- }
192
- }
193
- </script>
@@ -6,6 +6,8 @@
6
6
  <title>Trace {{ trace.trace_id }} - Observer</title>
7
7
  {% tailwind_css %}
8
8
  {% htmx_js %}
9
+ <link rel="stylesheet" href="{{ asset('observer/observer.css') }}">
10
+ <script src="{{ asset('observer/observer.js') }}" defer></script>
9
11
  </head>
10
12
  <body class="bg-stone-950 text-stone-300 min-h-screen">
11
13
  <div class="container mx-auto p-6 max-w-6xl">
@@ -6,6 +6,8 @@
6
6
  <title>Shared Trace - {{ trace.trace_id }} - Observer</title>
7
7
  {% tailwind_css %}
8
8
  {% htmx_js %}
9
+ <link rel="stylesheet" href="{{ asset('observer/observer.css') }}">
10
+ <script src="{{ asset('observer/observer.js') }}" defer></script>
9
11
  </head>
10
12
  <body class="bg-stone-950 text-stone-300 min-h-screen">
11
13
  <div class="container mx-auto p-6 max-w-6xl">
@@ -6,7 +6,9 @@
6
6
  <title>Observer Traces</title>
7
7
  {% tailwind_css %}
8
8
  {% htmx_js %}
9
- <script>
9
+ <link rel="stylesheet" href="{{ asset('observer/observer.css') }}">
10
+ <script src="{{ asset('observer/observer.js') }}" defer></script>
11
+ <script nonce="{{ request.csp_nonce }}">
10
12
  if (window.self !== window.top) {
11
13
  document.addEventListener('DOMContentLoaded', function() {
12
14
  document.body.setAttribute('data-iframe', 'true');
@@ -3,7 +3,7 @@
3
3
  <p>Loading spans...</p>
4
4
  </div>
5
5
  </div>
6
- <script>
6
+ <script nonce="{{ request.csp_nonce }}">
7
7
  (function() {
8
8
  var container = document.getElementById('observer-traces');
9
9
  var loaded = false;
@@ -29,8 +29,8 @@
29
29
  var iframe = document.createElement('iframe');
30
30
  iframe.src = "{{ url('observer:traces') }}";
31
31
  iframe.frameBorder = "0";
32
- iframe.style.width = "100%";
33
- iframe.style.height = "100%";
32
+ iframe.width = "100%";
33
+ iframe.height = "100%";
34
34
  container.innerHTML = '';
35
35
  container.appendChild(iframe);
36
36
  observer.disconnect();
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.observer
3
- Version: 0.12.0
3
+ Version: 0.13.0
4
4
  Summary: On-page telemetry and observability tools for Plain.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-Expression: BSD-3-Clause
@@ -16,6 +16,7 @@ Description-Content-Type: text/markdown
16
16
  **On-page telemetry and observability tools for Plain.**
17
17
 
18
18
  - [Installation](#installation)
19
+ - [Content Security Policy (CSP)](#content-security-policy-csp)
19
20
 
20
21
  ## Installation
21
22
 
@@ -57,3 +58,25 @@ plain migrate
57
58
  ```
58
59
 
59
60
  After installation, Observer will automatically integrate with your application's toolbar (if using `plain.admin`). You can access the web interface at `/observer/traces/` or use the CLI commands to analyze traces.
61
+
62
+ ## Content Security Policy (CSP)
63
+
64
+ If you're using a Content Security Policy (CSP), the Observer toolbar panel requires `frame-ancestors 'self'` to display trace information in an iframe.
65
+
66
+ Without this directive, the toolbar panel will fail to load with a CSP error: `"Refused to frame... because an ancestor violates the following Content Security Policy directive: 'frame-ancestors 'none'"`.
67
+
68
+ Example CSP configuration:
69
+
70
+ ```python
71
+ def DEFAULT_RESPONSE_HEADERS(request):
72
+ nonce = request.csp_nonce
73
+ return {
74
+ "Content-Security-Policy": (
75
+ f"default-src 'self'; "
76
+ f"script-src 'self' 'nonce-{nonce}'; "
77
+ f"style-src 'self' 'nonce-{nonce}'; "
78
+ f"frame-ancestors 'self'; " # Required for Observer toolbar
79
+ # ... other directives
80
+ ),
81
+ }
82
+ ```
@@ -1,6 +1,6 @@
1
1
  plain/observer/AGENTS.md,sha256=w3vqd2VWGliBQl8ohEBOsHyo1KROoUuK10uDr_C5Xeo,334
2
- plain/observer/CHANGELOG.md,sha256=OhpemfY9b3PUlNaWM5DGzEtQvvOpKV6bbxHWm4EmOss,15913
3
- plain/observer/README.md,sha256=39RA17fgcyOkqeIWBOAg4Be6YZjoiDzK5PVOG-nseuY,988
2
+ plain/observer/CHANGELOG.md,sha256=WEGX1l0PbDgr2T4gZXjo2NMmjVi1Q93TDe4iTX9vPQs,17211
3
+ plain/observer/README.md,sha256=zheQQwkFGDrs5pChC1kzC2x_mpBA8Lh5X97C6khyywA,1870
4
4
  plain/observer/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
5
  plain/observer/admin.py,sha256=u4F9VYMwDkwzfVgMlLUqwaqDjtsf9RIJppzOA2NXWKc,3516
6
6
  plain/observer/cli.py,sha256=rzESdG8rYH6ILZ_5jA6oR362kCjTlimgzNqng89s2yg,21131
@@ -13,6 +13,8 @@ plain/observer/otel.py,sha256=QbWoSc2KxDDhGfBQeww_uKBy3DL1SCOomvmxzU_yAVM,16902
13
13
  plain/observer/toolbar.py,sha256=btFUZIOQu0dkXOz7dWay79S0eBpadtSdfGyHF0yRVY4,720
14
14
  plain/observer/urls.py,sha256=oLJoDjB7YMUx8z-MGql_xXSXkfacVKFp-aHNXaKzs38,376
15
15
  plain/observer/views.py,sha256=johyO4MaLvROCexelrPDw0O-mIylzstJS7bzN94NOBg,5424
16
+ plain/observer/assets/observer/observer.css,sha256=4DF_523mc40x33mYF-itTqESoBgwv2JI5MUaByUnUdI,1289
17
+ plain/observer/assets/observer/observer.js,sha256=1CypJqFBjbVn3IcBC9S0cbMVpE7LtoG19o9PljEhM4c,2496
16
18
  plain/observer/migrations/0001_initial.py,sha256=HVoSrd5V-IOqD1adADIAzqMH8xMlPwyLOFH6JcGFniI,3312
17
19
  plain/observer/migrations/0002_trace_share_created_at_trace_share_id_trace_summary_and_more.py,sha256=lgFqdn66zq7AoCvkC3MIvLXiE_Omup5EEMDSe1jpKOg,1765
18
20
  plain/observer/migrations/0003_span_plainobserv_span_id_e7ade3_idx.py,sha256=54G8GXi-PiFbcTU-HrXO14UN9PxhjDCY05TA9HHkkmk,527
@@ -20,15 +22,15 @@ plain/observer/migrations/0004_trace_app_name_trace_app_version.py,sha256=Qfp62t
20
22
  plain/observer/migrations/0005_log_log_plainobserv_trace_i_fcfb7d_idx_and_more.py,sha256=ubUAPgN_jKK_o9J3G38MihGgsP_LvBwAzgvc1N-Td9M,2167
21
23
  plain/observer/migrations/0006_remove_log_logger.py,sha256=ruuwsDEcilSFTimU52JMiHz9ENxRCmLyheX_71ZXxfo,362
22
24
  plain/observer/migrations/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
23
- plain/observer/templates/observer/trace.html,sha256=zHf6GIdNDYBD7pqWxVvfm0R3fMTS6ZiFrOuq8eFxwmI,11374
24
- plain/observer/templates/observer/trace_detail.html,sha256=86-YGCpg9gy31Pd2vDHB3LYRkJile-DsGD1Enjx9s5Y,943
25
- plain/observer/templates/observer/trace_share.html,sha256=HrYLti5BpX96-6Bm_37OObFvAJSYbZ3GZD-MwCi9hgA,525
26
- plain/observer/templates/observer/traces.html,sha256=48MFsDjNCrouAiIQoSD-CqeeyHIN8-24k7DuqAmiiVY,24531
27
- plain/observer/templates/observer/partials/log.html,sha256=Vpp0j-GLBfwTBFyZp_cv0jkOLpgxfd-8-RMBe6V4HbU,964
28
- plain/observer/templates/observer/partials/span.html,sha256=PaWZONsABwlS7mdKsJlYbhMSOzidq_WyOp6ifvFUgmM,18672
29
- plain/observer/templates/toolbar/observer.html,sha256=uaDKiWR7EYqC1kEXE-uHDlE7nfFEMR_zmOgvlKwQHJ4,1365
25
+ plain/observer/templates/observer/trace.html,sha256=20argqtoo9M1CniIGSjKAuqf4CX0Pxauu5g5IpmVmzc,9826
26
+ plain/observer/templates/observer/trace_detail.html,sha256=9XyNJRWBrGTiEskQeeGdoPspeV7Lpur6s5YYFWw0Gw4,1085
27
+ plain/observer/templates/observer/trace_share.html,sha256=hsAuM6_jYpNCHIzV_A60lzg-OdY2Gg5V8xVYG4d5G6c,667
28
+ plain/observer/templates/observer/traces.html,sha256=cXoj1dATYM_4-xfqlKn1jl136MAkPmD4g-ALeceYvD4,24705
29
+ plain/observer/templates/observer/partials/log.html,sha256=dNIp1Wuu6dLLpenDIiUFcCkpFCQe2u-8Xk8mhrE41x0,952
30
+ plain/observer/templates/observer/partials/span.html,sha256=toBqwwou_oYYjAJDRJ_a72xm0CZPpTqM0-u6A_iWctc,18880
31
+ plain/observer/templates/toolbar/observer.html,sha256=SI1y8KzXRMaI9XDqGxuk6KnjyZwU4yXzPfhWrwD52yM,1385
30
32
  plain/observer/templates/toolbar/observer_button.html,sha256=FMBJHKMGqpHWs-3Ei2PBCdVYZ_6vFw6-eKH_i4jQu-Q,1215
31
- plain_observer-0.12.0.dist-info/METADATA,sha256=xS8eVM5pDt_JoMneFhihJCqiOrA6nWEEl12kgAysCqo,1387
32
- plain_observer-0.12.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
33
- plain_observer-0.12.0.dist-info/licenses/LICENSE,sha256=YZdq6Pz8ivjs97eSVLRmoGDI1hjEikX6N49DfM0DWio,1500
34
- plain_observer-0.12.0.dist-info/RECORD,,
33
+ plain_observer-0.13.0.dist-info/METADATA,sha256=07wRHfuXZh44Wj66IgJhVx9FDuZ37agRV1Ep-bYBeiM,2269
34
+ plain_observer-0.13.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
35
+ plain_observer-0.13.0.dist-info/licenses/LICENSE,sha256=YZdq6Pz8ivjs97eSVLRmoGDI1hjEikX6N49DfM0DWio,1500
36
+ plain_observer-0.13.0.dist-info/RECORD,,