neutronium 0.1.0__tar.gz

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 (43) hide show
  1. neutronium-0.1.0/LICENSE +21 -0
  2. neutronium-0.1.0/PKG-INFO +77 -0
  3. neutronium-0.1.0/README.md +47 -0
  4. neutronium-0.1.0/neutronium/__init__.py +1 -0
  5. neutronium-0.1.0/neutronium/logging/__init__.py +0 -0
  6. neutronium-0.1.0/neutronium/logging/filters.py +55 -0
  7. neutronium-0.1.0/neutronium/logging/formatters.py +129 -0
  8. neutronium-0.1.0/neutronium/telemetry/__init__.py +19 -0
  9. neutronium-0.1.0/neutronium/telemetry/context.py +32 -0
  10. neutronium-0.1.0/neutronium/telemetry/facts.py +37 -0
  11. neutronium-0.1.0/neutronium/telemetry/sinks/__init__.py +4 -0
  12. neutronium-0.1.0/neutronium/telemetry/sinks/access_log.py +40 -0
  13. neutronium-0.1.0/neutronium/telemetry/sinks/base.py +23 -0
  14. neutronium-0.1.0/neutronium/threads/__init__.py +0 -0
  15. neutronium-0.1.0/neutronium/threads/thread_simple.py +72 -0
  16. neutronium-0.1.0/neutronium/utils/__init__.py +0 -0
  17. neutronium-0.1.0/neutronium/utils/aws.py +60 -0
  18. neutronium-0.1.0/neutronium/utils/email.py +23 -0
  19. neutronium-0.1.0/neutronium/utils/hash.py +52 -0
  20. neutronium-0.1.0/neutronium/utils/iterable.py +213 -0
  21. neutronium-0.1.0/neutronium/utils/json_patch.py +77 -0
  22. neutronium-0.1.0/neutronium/utils/memory.py +72 -0
  23. neutronium-0.1.0/neutronium/utils/params.py +70 -0
  24. neutronium-0.1.0/neutronium/utils/print.py +50 -0
  25. neutronium-0.1.0/neutronium/utils/profiling.py +79 -0
  26. neutronium-0.1.0/neutronium/utils/schema.py +32 -0
  27. neutronium-0.1.0/neutronium/utils/ssm.py +53 -0
  28. neutronium-0.1.0/neutronium/utils/template_context.py +657 -0
  29. neutronium-0.1.0/neutronium/utils/text.py +830 -0
  30. neutronium-0.1.0/neutronium/utils/time.py +121 -0
  31. neutronium-0.1.0/neutronium/utils/url_credentials.py +43 -0
  32. neutronium-0.1.0/neutronium/utils/xpath.py +52 -0
  33. neutronium-0.1.0/neutronium.egg-info/PKG-INFO +77 -0
  34. neutronium-0.1.0/neutronium.egg-info/SOURCES.txt +41 -0
  35. neutronium-0.1.0/neutronium.egg-info/dependency_links.txt +1 -0
  36. neutronium-0.1.0/neutronium.egg-info/requires.txt +25 -0
  37. neutronium-0.1.0/neutronium.egg-info/top_level.txt +1 -0
  38. neutronium-0.1.0/pyproject.toml +43 -0
  39. neutronium-0.1.0/setup.cfg +4 -0
  40. neutronium-0.1.0/tests/test_email.py +18 -0
  41. neutronium-0.1.0/tests/test_template_context.py +621 -0
  42. neutronium-0.1.0/tests/test_text.py +30 -0
  43. neutronium-0.1.0/tests/test_xpath.py +38 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Gaussian Holdings, LLC
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.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: neutronium
3
+ Version: 0.1.0
4
+ Summary: Framework-agnostic Python utilities: text, time, iterables, structured logging, telemetry primitives, and more.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/gaussian/neutronium
7
+ Project-URL: Issues, https://github.com/gaussian/neutronium/issues
8
+ Requires-Python: >=3.12
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Provides-Extra: text
12
+ Requires-Dist: inflection; extra == "text"
13
+ Provides-Extra: time
14
+ Requires-Dist: python-dateutil; extra == "time"
15
+ Provides-Extra: memory
16
+ Requires-Dist: psutil; extra == "memory"
17
+ Requires-Dist: pympler; extra == "memory"
18
+ Requires-Dist: objgraph; extra == "memory"
19
+ Provides-Extra: aws
20
+ Requires-Dist: boto3>=1.36; extra == "aws"
21
+ Requires-Dist: requests>=2.23; extra == "aws"
22
+ Requires-Dist: ec2-metadata; extra == "aws"
23
+ Provides-Extra: xpath
24
+ Requires-Dist: lxml; extra == "xpath"
25
+ Provides-Extra: otel
26
+ Requires-Dist: opentelemetry-api; extra == "otel"
27
+ Provides-Extra: profiling
28
+ Requires-Dist: line_profiler; extra == "profiling"
29
+ Dynamic: license-file
30
+
31
+ # neutronium
32
+
33
+ Framework-agnostic Python utilities, extracted from Gaussian's internal
34
+ infrastructure. No Django, no web framework, no cloud SDK in the base install —
35
+ everything third-party is an optional extra.
36
+
37
+ ```sh
38
+ pip install neutronium # base: zero dependencies
39
+ pip install neutronium[text] # inflection-backed pluralize/singularize
40
+ pip install neutronium[xpath] # lxml-backed HTML/XPath helpers
41
+ ```
42
+
43
+ ## What's inside
44
+
45
+ | Module | What it gives you |
46
+ |---|---|
47
+ | `neutronium.utils.text` | Text normalization, slugs, tokenization, pluralize/singularize |
48
+ | `neutronium.utils.time` | Timezone-aware datetime helpers |
49
+ | `neutronium.utils.iterable` | Chunking, grouping, flattening, dict utilities |
50
+ | `neutronium.utils.template_context` | Pure template-variable extraction and context building |
51
+ | `neutronium.utils.json_patch` | RFC-6901 JSON pointer / merge-patch primitives |
52
+ | `neutronium.utils.hash` | `blake2b` content hashing helpers |
53
+ | `neutronium.utils.schema` | JSON-schema → default-dict |
54
+ | `neutronium.utils.xpath` | lxml HTML parsing + link extraction (`[xpath]`) |
55
+ | `neutronium.utils.email` | Canonical email normalization |
56
+ | `neutronium.utils.{aws,ssm}` | EC2/ECS instance metadata + SSM parameter fetch (`[aws]`) |
57
+ | `neutronium.utils.{print,params,profiling,memory,url_credentials}` | Assorted small utilities |
58
+ | `neutronium.threads.thread_simple` | Minimal threading helpers |
59
+ | `neutronium.telemetry` | Request-context vars, `RequestFacts`, pluggable sinks |
60
+ | `neutronium.logging` | JSON / dev-console log formatters and context filters |
61
+
62
+ ## Optional extras
63
+
64
+ `text`, `time`, `aws`, `memory`, `xpath`, `otel`, `profiling` — install only what you use.
65
+
66
+ - `aws` → `boto3`, `requests`, `ec2-metadata` (for `neutronium.utils.aws` / `ssm`)
67
+
68
+ ## Development
69
+
70
+ ```sh
71
+ uv sync --group dev
72
+ uv run pytest
73
+ ```
74
+
75
+ ## License
76
+
77
+ MIT © Gaussian Holdings, LLC
@@ -0,0 +1,47 @@
1
+ # neutronium
2
+
3
+ Framework-agnostic Python utilities, extracted from Gaussian's internal
4
+ infrastructure. No Django, no web framework, no cloud SDK in the base install —
5
+ everything third-party is an optional extra.
6
+
7
+ ```sh
8
+ pip install neutronium # base: zero dependencies
9
+ pip install neutronium[text] # inflection-backed pluralize/singularize
10
+ pip install neutronium[xpath] # lxml-backed HTML/XPath helpers
11
+ ```
12
+
13
+ ## What's inside
14
+
15
+ | Module | What it gives you |
16
+ |---|---|
17
+ | `neutronium.utils.text` | Text normalization, slugs, tokenization, pluralize/singularize |
18
+ | `neutronium.utils.time` | Timezone-aware datetime helpers |
19
+ | `neutronium.utils.iterable` | Chunking, grouping, flattening, dict utilities |
20
+ | `neutronium.utils.template_context` | Pure template-variable extraction and context building |
21
+ | `neutronium.utils.json_patch` | RFC-6901 JSON pointer / merge-patch primitives |
22
+ | `neutronium.utils.hash` | `blake2b` content hashing helpers |
23
+ | `neutronium.utils.schema` | JSON-schema → default-dict |
24
+ | `neutronium.utils.xpath` | lxml HTML parsing + link extraction (`[xpath]`) |
25
+ | `neutronium.utils.email` | Canonical email normalization |
26
+ | `neutronium.utils.{aws,ssm}` | EC2/ECS instance metadata + SSM parameter fetch (`[aws]`) |
27
+ | `neutronium.utils.{print,params,profiling,memory,url_credentials}` | Assorted small utilities |
28
+ | `neutronium.threads.thread_simple` | Minimal threading helpers |
29
+ | `neutronium.telemetry` | Request-context vars, `RequestFacts`, pluggable sinks |
30
+ | `neutronium.logging` | JSON / dev-console log formatters and context filters |
31
+
32
+ ## Optional extras
33
+
34
+ `text`, `time`, `aws`, `memory`, `xpath`, `otel`, `profiling` — install only what you use.
35
+
36
+ - `aws` → `boto3`, `requests`, `ec2-metadata` (for `neutronium.utils.aws` / `ssm`)
37
+
38
+ ## Development
39
+
40
+ ```sh
41
+ uv sync --group dev
42
+ uv run pytest
43
+ ```
44
+
45
+ ## License
46
+
47
+ MIT © Gaussian Holdings, LLC
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,55 @@
1
+ import logging
2
+
3
+ try:
4
+ from opentelemetry import trace
5
+ except ImportError:
6
+ trace = None # OpenTelemetry not installed
7
+
8
+ from neutronium.telemetry.context import (
9
+ client_ip_var,
10
+ enterprise_id_var,
11
+ request_id_var,
12
+ user_id_var,
13
+ )
14
+
15
+
16
+ def _trace_id_hex(ctx) -> str:
17
+ """Convert an OTel trace ID integer to hex string."""
18
+ return format(ctx.trace_id, "032x")
19
+
20
+
21
+ def _span_id_hex(ctx) -> str:
22
+ """Convert an OTel span ID integer to hex string."""
23
+ return format(ctx.span_id, "016x")
24
+
25
+
26
+ class ContextFilter(logging.Filter):
27
+ """
28
+ Logging filter that injects request context and OpenTelemetry/X-Ray IDs into log records.
29
+ """
30
+
31
+ def filter(self, record: logging.LogRecord) -> bool:
32
+ # App context from contextvars
33
+ record.request_id = request_id_var.get()
34
+ record.user_id = user_id_var.get()
35
+ record.client_ip = client_ip_var.get()
36
+ record.enterprise_id = enterprise_id_var.get()
37
+
38
+ # OTel / X-Ray context (trace_id, span_id)
39
+ if trace:
40
+ ctx = trace.get_current_span().get_span_context()
41
+ if ctx.is_valid:
42
+ record.trace_id = _trace_id_hex(ctx)
43
+ record.span_id = _span_id_hex(ctx)
44
+ record.trace_sampled = bool(ctx.trace_flags.sampled)
45
+ else:
46
+ record.trace_id = None
47
+ record.span_id = None
48
+ record.trace_sampled = None
49
+ else:
50
+ # OpenTelemetry not installed, skip trace context
51
+ record.trace_id = None
52
+ record.span_id = None
53
+ record.trace_sampled = None
54
+
55
+ return True
@@ -0,0 +1,129 @@
1
+ import json
2
+ import logging
3
+ import os
4
+ from datetime import datetime, timezone
5
+ from uuid import UUID
6
+
7
+ SERVICE_NAME = os.environ.get("OTEL_SERVICE_NAME")
8
+
9
+
10
+ def _json_default(obj):
11
+ """Handle JSON serialization of non-standard types."""
12
+ if isinstance(obj, UUID):
13
+ return str(obj)
14
+ raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
15
+
16
+
17
+ class JsonFormatter(logging.Formatter):
18
+ """JSON formatter with ordered fields and null value omission."""
19
+
20
+ # Maps record attribute names to JSON output field names
21
+ CONTEXT_FIELDS = {
22
+ "request_id": "request_id",
23
+ "client_ip": "client_ip",
24
+ "user_id": "user.id",
25
+ "enterprise_id": "enterprise.id",
26
+ "trace_id": "trace_id",
27
+ "span_id": "span_id",
28
+ "trace_sampled": "trace.sampled",
29
+ # HTTP request fields from AccessLogMiddleware
30
+ "http.method": "http.method",
31
+ "http.path": "http.path",
32
+ "http.route": "http.route",
33
+ "http.status_code": "http.status_code",
34
+ "http.user_agent": "http.user_agent",
35
+ "duration_ms": "duration_ms",
36
+ "user.is_privileged": "user.is_privileged",
37
+ "django.view": "django.view",
38
+ }
39
+
40
+ def format(self, record: logging.LogRecord) -> str:
41
+ # Build payload with explicit field ordering
42
+ payload = {
43
+ "timestamp": datetime.fromtimestamp(
44
+ record.created, tz=timezone.utc
45
+ ).isoformat(),
46
+ "level": record.levelname.lower(),
47
+ "message": record.getMessage(),
48
+ "logger": record.name,
49
+ }
50
+
51
+ if SERVICE_NAME:
52
+ payload["service.name"] = SERVICE_NAME
53
+
54
+ # Add context fields (injected by ContextFilter)
55
+ for attr, output_name in self.CONTEXT_FIELDS.items():
56
+ value = getattr(record, attr, None)
57
+ if value is not None:
58
+ payload[output_name] = value
59
+
60
+ # Include structured exception info if present
61
+ if record.exc_info:
62
+ exc_type, exc_value, _ = record.exc_info
63
+ if exc_type is not None:
64
+ payload["error.type"] = exc_type.__name__
65
+ if exc_value is not None:
66
+ payload["error.message"] = str(exc_value)
67
+ payload["error.stack_trace"] = self.formatException(record.exc_info)
68
+
69
+ # Include stack_info if requested (e.g., logger.info("msg", stack_info=True))
70
+ if record.stack_info:
71
+ payload["stack_info"] = self.formatStack(record.stack_info)
72
+
73
+ return json.dumps(payload, default=_json_default)
74
+
75
+
76
+ class DevConsoleFormatter(logging.Formatter):
77
+ """Human-readable formatter for local development."""
78
+
79
+ # Standard LogRecord attributes to exclude when looking for extra fields
80
+ _STANDARD_ATTRS = frozenset(
81
+ {
82
+ "name",
83
+ "msg",
84
+ "args",
85
+ "created",
86
+ "filename",
87
+ "funcName",
88
+ "levelname",
89
+ "levelno",
90
+ "lineno",
91
+ "module",
92
+ "msecs",
93
+ "pathname",
94
+ "process",
95
+ "processName",
96
+ "thread",
97
+ "threadName",
98
+ "exc_info",
99
+ "exc_text",
100
+ "stack_info",
101
+ "message",
102
+ "relativeCreated",
103
+ "taskName",
104
+ }
105
+ )
106
+
107
+ def format(self, record: logging.LogRecord) -> str:
108
+ base = f"[{record.levelname}] {record.getMessage()} ({record.pathname}:{record.lineno})"
109
+
110
+ # Collect extra fields (anything not in standard LogRecord attributes)
111
+ extras = {
112
+ k: v
113
+ for k, v in record.__dict__.items()
114
+ if k not in self._STANDARD_ATTRS and not k.startswith("_")
115
+ }
116
+
117
+ if extras:
118
+ extra_str = " ".join(f"{k}={v!r}" for k, v in extras.items())
119
+ base = f"{base} | {extra_str}"
120
+
121
+ # Include exception info if present
122
+ if record.exc_info:
123
+ base = f"{base}\n{self.formatException(record.exc_info)}"
124
+
125
+ # Include stack_info if requested (e.g., logger.info("msg", stack_info=True))
126
+ if record.stack_info:
127
+ base = f"{base}\n{self.formatStack(record.stack_info)}"
128
+
129
+ return base
@@ -0,0 +1,19 @@
1
+ from .context import (
2
+ clear_request_context,
3
+ client_ip_var,
4
+ enterprise_id_var,
5
+ request_id_var,
6
+ set_request_context,
7
+ user_id_var,
8
+ )
9
+ from .facts import RequestFacts
10
+
11
+ __all__ = [
12
+ "request_id_var",
13
+ "user_id_var",
14
+ "client_ip_var",
15
+ "enterprise_id_var",
16
+ "set_request_context",
17
+ "clear_request_context",
18
+ "RequestFacts",
19
+ ]
@@ -0,0 +1,32 @@
1
+ from contextvars import ContextVar
2
+
3
+ request_id_var: ContextVar[str | None] = ContextVar("request_id", default=None)
4
+ user_id_var: ContextVar[str | None] = ContextVar("user_id", default=None)
5
+ client_ip_var: ContextVar[str | None] = ContextVar("client_ip", default=None)
6
+ enterprise_id_var: ContextVar[str | None] = ContextVar("enterprise_id", default=None)
7
+
8
+
9
+ def set_request_context(
10
+ *,
11
+ request_id: str | None = None,
12
+ user_id: str | None = None,
13
+ client_ip: str | None = None,
14
+ enterprise_id: str | None = None,
15
+ ) -> None:
16
+ """Set request context variables for logging."""
17
+ if request_id is not None:
18
+ request_id_var.set(str(request_id))
19
+ if user_id is not None:
20
+ user_id_var.set(str(user_id))
21
+ if client_ip is not None:
22
+ client_ip_var.set(str(client_ip))
23
+ if enterprise_id is not None:
24
+ enterprise_id_var.set(str(enterprise_id))
25
+
26
+
27
+ def clear_request_context() -> None:
28
+ """Clear all request context variables."""
29
+ request_id_var.set(None)
30
+ user_id_var.set(None)
31
+ client_ip_var.set(None)
32
+ enterprise_id_var.set(None)
@@ -0,0 +1,37 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class RequestFacts:
6
+ """
7
+ Immutable container for request telemetry data.
8
+ Collected by RequestTelemetryMiddleware and passed to all configured sinks.
9
+ """
10
+
11
+ # Request identification
12
+ request_id: str | None = None
13
+
14
+ # Enterprise context (from X-ENTERPRISE-ID header)
15
+ enterprise_id: str | None = None
16
+
17
+ # User context
18
+ user_id: int | None = None
19
+ user_is_authenticated: bool = False
20
+ user_is_staff: bool = False
21
+
22
+ # HTTP request info
23
+ method: str = ""
24
+ host: str = ""
25
+ path: str = ""
26
+ route: str | None = None
27
+ view_name: str | None = None
28
+
29
+ # Response info
30
+ status_code: int | None = None
31
+
32
+ # Timing
33
+ duration_ms: int | None = None
34
+
35
+ # Client info
36
+ client_ip: str | None = None
37
+ user_agent: str = ""
@@ -0,0 +1,4 @@
1
+ from .access_log import AccessLogSink
2
+ from .base import TelemetrySink
3
+
4
+ __all__ = ["TelemetrySink", "AccessLogSink"]
@@ -0,0 +1,40 @@
1
+ import logging
2
+
3
+ from neutronium.telemetry.facts import RequestFacts
4
+
5
+ audit_logger = logging.getLogger("app.audit.request")
6
+
7
+
8
+ class AccessLogSink:
9
+ """
10
+ Sink that logs authenticated HTTP requests to the audit logger.
11
+
12
+ Replicates the behavior of the legacy AccessLogMiddleware:
13
+ - Only logs authenticated requests
14
+ - Uses the same log message and extra fields
15
+ - Writes to 'app.audit.request' logger
16
+ """
17
+
18
+ def emit(self, facts: RequestFacts) -> None:
19
+ """
20
+ Log authenticated request to the audit logger.
21
+
22
+ Only logs if the request was from an authenticated user,
23
+ matching the behavior of the original AccessLogMiddleware.
24
+ """
25
+ if not facts.user_is_authenticated:
26
+ return
27
+
28
+ audit_logger.info(
29
+ "Authenticated HTTP request",
30
+ extra={
31
+ "http.method": facts.method,
32
+ "http.path": facts.path,
33
+ "http.route": facts.route,
34
+ "http.status_code": facts.status_code,
35
+ "http.user_agent": facts.user_agent[:512] if facts.user_agent else "",
36
+ "duration_ms": facts.duration_ms,
37
+ "user.is_privileged": facts.user_is_staff,
38
+ "django.view": facts.view_name,
39
+ },
40
+ )
@@ -0,0 +1,23 @@
1
+ from typing import Protocol
2
+
3
+ from neutronium.telemetry.facts import RequestFacts
4
+
5
+
6
+ class TelemetrySink(Protocol):
7
+ """
8
+ Protocol defining the interface for request telemetry sinks.
9
+
10
+ Sinks receive RequestFacts after each request and can process them
11
+ in various ways (logging, analytics, metrics, etc.).
12
+
13
+ Each sink should be independent and not know about other sinks.
14
+ """
15
+
16
+ def emit(self, facts: RequestFacts) -> None:
17
+ """
18
+ Process the request telemetry data.
19
+
20
+ Args:
21
+ facts: Immutable container with all request telemetry data.
22
+ """
23
+ ...
File without changes
@@ -0,0 +1,72 @@
1
+ import threading
2
+
3
+
4
+ class SimpleThread(threading.Thread):
5
+ result = None
6
+
7
+ def __init__(self, func, run_sync=False, *args, **kwargs):
8
+ super().__init__()
9
+ self.kwargs = kwargs
10
+ # for kwarg in kwargs.items():
11
+ # setattr(self, kwarg[0], kwarg[1])
12
+ self.func = func
13
+ self.run_sync = run_sync
14
+ self.exception = None
15
+
16
+ def start(self):
17
+ # Allow for override to run synchronously...
18
+ if self.run_sync:
19
+ self.run()
20
+ else:
21
+ super().start()
22
+
23
+ def run(self):
24
+ try:
25
+ self.result = self.func(**self.kwargs)
26
+ except Exception as e:
27
+ self.exception = e
28
+
29
+ def get_result(self):
30
+ if not self.run_sync:
31
+ self.join()
32
+ return self.result
33
+
34
+
35
+ class SimpleThreadManager:
36
+
37
+ def __init__(self, pass_through_exceptions=False):
38
+ self.threads = []
39
+ self.results_list = []
40
+ self.results_total = 0
41
+ self.pass_through_exceptions = pass_through_exceptions
42
+
43
+ def start_thread(self, simple_thread):
44
+ """
45
+ Add thread to the threads list and start it running.
46
+ :param simple_thread:
47
+ :type: SimpleThread
48
+ :return:
49
+ """
50
+ simple_thread.start()
51
+ self.threads.append(simple_thread)
52
+
53
+ def wait_return_results_as_list(self):
54
+ for thread in self.threads:
55
+ result = thread.get_result()
56
+ if thread.exception and self.pass_through_exceptions:
57
+ raise thread.exception
58
+ if result is not None:
59
+ if isinstance(result, (list, set)):
60
+ self.results_list += result
61
+ else:
62
+ self.results_list.append(result)
63
+ self.threads = []
64
+ return self.results_list
65
+
66
+ def wait_return_results_as_total(self):
67
+ for thread in self.threads:
68
+ result = thread.get_result()
69
+ if result:
70
+ self.results_total += result
71
+ self.threads = []
72
+ return self.results_total
File without changes
@@ -0,0 +1,60 @@
1
+ def get_instance_metadata():
2
+ import os
3
+ from dataclasses import dataclass
4
+
5
+ @dataclass
6
+ class InstanceMetadata:
7
+ private_ipv4: str | None = None
8
+ region: str | None = None
9
+
10
+ env_region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION")
11
+
12
+ ecs_metadata_uri = os.environ.get("ECS_CONTAINER_METADATA_URI_V4")
13
+ if ecs_metadata_uri:
14
+ try:
15
+ import requests
16
+
17
+ r = requests.get(f"{ecs_metadata_uri}/task", timeout=2)
18
+ r.raise_for_status()
19
+ task = r.json()
20
+
21
+ private_ip = None
22
+ for c in task.get("Containers", []):
23
+ for n in c.get("Networks", []):
24
+ ipv4s = n.get("IPv4Addresses") or []
25
+ if ipv4s:
26
+ private_ip = ipv4s[0]
27
+ break
28
+ if private_ip:
29
+ break
30
+
31
+ region = env_region
32
+ if not region:
33
+ task_arn = task.get("TaskARN") or ""
34
+ parts = task_arn.split(":")
35
+ if len(parts) > 2:
36
+ region = parts[2] # arn:aws:ecs:REGION:...
37
+
38
+ return InstanceMetadata(private_ipv4=private_ip, region=region)
39
+ except Exception:
40
+ pass
41
+
42
+ try:
43
+ from ec2_metadata import ec2_metadata
44
+
45
+ rel = os.environ.get("AWS_CONTAINER_CREDENTIALS_RELATIVE_URI")
46
+ if rel:
47
+ ec2_metadata.SERVICE_URL = f"169.254.170.2{rel}"
48
+ ec2_metadata.DYNAMIC_URL = ec2_metadata.SERVICE_URL + "dynamic/"
49
+ ec2_metadata.METADATA_URL = ec2_metadata.SERVICE_URL + "meta-data/"
50
+ ec2_metadata.USERDATA_URL = ec2_metadata.SERVICE_URL + "user-data/"
51
+
52
+ region = getattr(ec2_metadata, "region", None) or env_region
53
+ return InstanceMetadata(
54
+ private_ipv4=getattr(ec2_metadata, "private_ipv4", None),
55
+ region=region,
56
+ )
57
+ except Exception:
58
+ pass
59
+
60
+ return InstanceMetadata(region=env_region)
@@ -0,0 +1,23 @@
1
+ def get_canonical_email(email=None):
2
+ """
3
+ :param email:
4
+ :return: Returns the lowercase canonical email without `+` or `.` characters.
5
+ """
6
+
7
+ if not email:
8
+ return None
9
+
10
+ # Convert email to lowercase
11
+ email = email.lower()
12
+
13
+ delimited_email = email.split('@')
14
+ domain = delimited_email[-1]
15
+
16
+ # Get the significand of an email address (removing any extra @'s)
17
+ significand = ''.join(delimited_email[0:-1])
18
+
19
+ # Strip substring from first `+` to the `@`
20
+ if '+' in significand:
21
+ significand = significand[0:significand.find('+')]
22
+
23
+ return '@'.join([significand, domain])