dlogify 0.1.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.
dlogify/__init__.py ADDED
@@ -0,0 +1,160 @@
1
+ """dlogify: send Python logs and uncaught exceptions to Logify.
2
+
3
+ import dlogify
4
+ dlogify.init()
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from collections.abc import Mapping
11
+ from typing import Optional
12
+
13
+ from . import _singleton
14
+ from ._client import Client
15
+ from ._diagnostics import Diagnostics, is_flag
16
+ from ._version import VERSION as __version__
17
+ from .handler import LogifyHandler
18
+
19
+ __all__ = [
20
+ "init",
21
+ "trace",
22
+ "debug",
23
+ "info",
24
+ "warn",
25
+ "warning",
26
+ "error",
27
+ "fatal",
28
+ "critical",
29
+ "log",
30
+ "capture_exception",
31
+ "flush",
32
+ "shutdown",
33
+ "Client",
34
+ "LogifyHandler",
35
+ "__version__",
36
+ ]
37
+
38
+ _noted_before_init = False
39
+
40
+
41
+ def _active() -> Optional[Client]:
42
+ global _noted_before_init
43
+ client = _singleton.current
44
+ if client is None and not _noted_before_init:
45
+ _noted_before_init = True
46
+ try:
47
+ Diagnostics(is_flag(os.environ.get("LOGIFY_DEBUG"))).debug("a log call before init() was dropped")
48
+ except Exception:
49
+ pass
50
+ return client
51
+
52
+
53
+ def init(
54
+ *,
55
+ api_key: Optional[str] = None,
56
+ endpoint: Optional[str] = None,
57
+ service: Optional[str] = None,
58
+ environment: Optional[str] = None,
59
+ release: Optional[str] = None,
60
+ min_level: Optional[str] = None,
61
+ capture_logging: bool = True,
62
+ capture_uncaught: bool = True,
63
+ attributes: Optional[Mapping[str, object]] = None,
64
+ max_queue_size: Optional[int] = None,
65
+ disabled: Optional[bool] = None,
66
+ debug: Optional[bool] = None,
67
+ ) -> None:
68
+ """Starts the client: captures logging and uncaught exceptions unless turned off.
69
+ Safe to call from any thread; a second call is ignored."""
70
+ try:
71
+ with _singleton.lock:
72
+ existing = _singleton.current
73
+ if existing is not None:
74
+ existing.diagnostics.debug("init() was called again; ignored")
75
+ return
76
+ _singleton.current = Client(
77
+ api_key=api_key,
78
+ endpoint=endpoint,
79
+ service=service,
80
+ environment=environment,
81
+ release=release,
82
+ min_level=min_level,
83
+ capture_logging=capture_logging,
84
+ capture_uncaught=capture_uncaught,
85
+ attributes=attributes,
86
+ max_queue_size=max_queue_size,
87
+ disabled=disabled,
88
+ debug=debug,
89
+ )
90
+ except Exception:
91
+ pass # init() never raises
92
+
93
+
94
+ def trace(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
95
+ client = _active()
96
+ if client is not None:
97
+ client.trace(message, exc=exc, attributes=attributes)
98
+
99
+
100
+ def debug(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
101
+ client = _active()
102
+ if client is not None:
103
+ client.debug(message, exc=exc, attributes=attributes)
104
+
105
+
106
+ def info(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
107
+ client = _active()
108
+ if client is not None:
109
+ client.info(message, exc=exc, attributes=attributes)
110
+
111
+
112
+ def warn(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
113
+ client = _active()
114
+ if client is not None:
115
+ client.warn(message, exc=exc, attributes=attributes)
116
+
117
+
118
+ warning = warn
119
+
120
+
121
+ def error(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
122
+ client = _active()
123
+ if client is not None:
124
+ client.error(message, exc=exc, attributes=attributes)
125
+
126
+
127
+ def fatal(message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
128
+ client = _active()
129
+ if client is not None:
130
+ client.fatal(message, exc=exc, attributes=attributes)
131
+
132
+
133
+ critical = fatal
134
+
135
+
136
+ def log(level: str, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
137
+ client = _active()
138
+ if client is not None:
139
+ client.log(level, message, exc=exc, attributes=attributes)
140
+
141
+
142
+ def capture_exception(exc: Optional[BaseException] = None, *, attributes: Optional[Mapping[str, object]] = None) -> None:
143
+ client = _active()
144
+ if client is not None:
145
+ client.capture_exception(exc, attributes=attributes)
146
+
147
+
148
+ def flush(timeout: float = 2.0) -> None:
149
+ client = _singleton.current
150
+ if client is not None:
151
+ client.flush(timeout)
152
+
153
+
154
+ def shutdown(timeout: float = 2.0) -> None:
155
+ """Flushes, stops and restores what init() installed; init() may run again."""
156
+ with _singleton.lock:
157
+ client = _singleton.current
158
+ _singleton.current = None
159
+ if client is not None:
160
+ client.shutdown(timeout)
dlogify/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .run.cli import main
2
+
3
+ main()
@@ -0,0 +1,56 @@
1
+ """init() hands every logging record to its LogifyHandler by wrapping
2
+ Logger.callHandlers instead of adding a handler to the root logger: a root
3
+ logger without handlers keeps logging.basicConfig() and logging.lastResort
4
+ working (spec D3)."""
5
+
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import threading
10
+ from typing import Callable
11
+
12
+ _lock = threading.Lock()
13
+ _handlers: list[logging.Handler] = []
14
+ # The wrapper we installed last, while Logger.callHandlers is still it.
15
+ _installed: "Callable | None" = None
16
+
17
+
18
+ def _wrap(previous: Callable) -> Callable:
19
+ def call_handlers(self: logging.Logger, record: logging.LogRecord) -> None:
20
+ try:
21
+ previous(self, record)
22
+ finally:
23
+ # Another library may have wrapped us and been wrapped again: each
24
+ # record reaches our handlers once.
25
+ if not getattr(record, "_dlogify_seen", False):
26
+ try:
27
+ record._dlogify_seen = True
28
+ for handler in tuple(_handlers):
29
+ if record.levelno >= handler.level:
30
+ handler.handle(record)
31
+ except Exception:
32
+ pass # never break the application's logging call
33
+
34
+ call_handlers._dlogify_previous = previous # type: ignore[attr-defined]
35
+ return call_handlers
36
+
37
+
38
+ def install_logging_capture(handler: logging.Handler) -> Callable[[], None]:
39
+ global _installed
40
+ with _lock:
41
+ current = logging.Logger.callHandlers
42
+ if not hasattr(current, "_dlogify_previous"):
43
+ _installed = _wrap(current)
44
+ logging.Logger.callHandlers = _installed # type: ignore[method-assign]
45
+ _handlers.append(handler)
46
+
47
+ def undo() -> None:
48
+ global _installed
49
+ with _lock:
50
+ if handler in _handlers:
51
+ _handlers.remove(handler)
52
+ if not _handlers and _installed is not None and logging.Logger.callHandlers is _installed:
53
+ logging.Logger.callHandlers = _installed._dlogify_previous # type: ignore[attr-defined,method-assign]
54
+ _installed = None
55
+
56
+ return undo
dlogify/_client.py ADDED
@@ -0,0 +1,239 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import math
5
+ import os
6
+ import sys
7
+ import threading
8
+ from collections.abc import Mapping
9
+ from dataclasses import dataclass, field
10
+ from typing import Any, Callable, Optional
11
+
12
+ from ._capture_logging import install_logging_capture
13
+ from ._config import ResolvedConfig, disabled_config, resolve_config
14
+ from ._diagnostics import Diagnostics, describe_error
15
+ from ._encode import resource_attributes
16
+ from ._exporter import Exporter
17
+ from ._guard import guarded
18
+ from ._hooks import ExitBudget, install_exit_flush, install_uncaught_capture
19
+ from ._levels import LEVELNO, Level, level_from_word, level_rank
20
+ from ._record import (
21
+ LogRecord,
22
+ exception_attributes,
23
+ exception_title,
24
+ finalize_record,
25
+ normalize_attributes,
26
+ normalize_value,
27
+ now_unix_nano,
28
+ )
29
+ from ._version import USER_AGENT
30
+ from .handler import LogifyHandler
31
+
32
+ DEFAULT_FLUSH_TIMEOUT = 2.0
33
+
34
+
35
+ @dataclass
36
+ class Internals:
37
+ """Not part of the documented API: tests and `logify run` use it."""
38
+
39
+ env: Optional[Mapping[str, str]] = None
40
+ cwd: Optional[str] = None
41
+ user_agent: Optional[str] = None
42
+ exporter: dict[str, Any] = field(default_factory=dict)
43
+
44
+
45
+ def _valid_timeout(timeout: object) -> float:
46
+ if isinstance(timeout, (int, float)) and not isinstance(timeout, bool) and math.isfinite(timeout) and timeout >= 0:
47
+ return float(timeout)
48
+ return DEFAULT_FLUSH_TIMEOUT
49
+
50
+
51
+ def _as_text(value: object) -> str:
52
+ if isinstance(value, str):
53
+ return value
54
+ normalized = normalize_value(value)
55
+ if normalized is None:
56
+ return ""
57
+ return normalized if isinstance(normalized, str) else json.dumps(normalized)
58
+
59
+
60
+ class Client:
61
+ def __init__(
62
+ self,
63
+ *,
64
+ api_key: Optional[str] = None,
65
+ endpoint: Optional[str] = None,
66
+ service: Optional[str] = None,
67
+ environment: Optional[str] = None,
68
+ release: Optional[str] = None,
69
+ min_level: Optional[str] = None,
70
+ capture_logging: bool = True,
71
+ capture_uncaught: bool = True,
72
+ attributes: Optional[Mapping[str, object]] = None,
73
+ max_queue_size: Optional[int] = None,
74
+ disabled: Optional[bool] = None,
75
+ debug: Optional[bool] = None,
76
+ _internals: Optional[Internals] = None,
77
+ ) -> None:
78
+ internals = _internals or Internals()
79
+ options = {
80
+ "api_key": api_key,
81
+ "endpoint": endpoint,
82
+ "service": service,
83
+ "environment": environment,
84
+ "release": release,
85
+ "min_level": min_level,
86
+ "capture_logging": capture_logging,
87
+ "capture_uncaught": capture_uncaught,
88
+ "attributes": attributes,
89
+ "max_queue_size": max_queue_size,
90
+ "disabled": disabled,
91
+ "debug": debug,
92
+ }
93
+ try:
94
+ env = os.environ if internals.env is None else internals.env
95
+ cwd = os.getcwd() if internals.cwd is None else internals.cwd
96
+ config, warnings = resolve_config(options, env, cwd)
97
+ except Exception as err:
98
+ config, warnings = disabled_config(), [f"invalid configuration ({describe_error(err)}); Logify is disabled"]
99
+ self.config: ResolvedConfig = config
100
+ self.diagnostics = Diagnostics(config.debug)
101
+ self._exporter: Optional[Exporter] = None
102
+ # Undo steps for everything this client installed, run by shutdown().
103
+ self._undo: list[Callable[[], None]] = []
104
+ self._lock = threading.Lock()
105
+ self._shut_down = False
106
+ self._closed = False
107
+ for warning in warnings:
108
+ self.diagnostics.warn(warning)
109
+ if not config.enabled:
110
+ return
111
+ try:
112
+ self._exporter = Exporter(
113
+ url=config.logs_url,
114
+ api_key=config.api_key,
115
+ user_agent=internals.user_agent or USER_AGENT,
116
+ resource=resource_attributes(
117
+ service=config.service, environment=config.environment, release=config.release, attributes=config.attributes
118
+ ),
119
+ max_queue_size=config.max_queue_size,
120
+ diagnostics=self.diagnostics,
121
+ **internals.exporter,
122
+ )
123
+ self._install()
124
+ except Exception as err:
125
+ self.diagnostics.warn(f"could not start: {describe_error(err)}")
126
+
127
+ def _install(self) -> None:
128
+ """Installs the captures the configuration asks for."""
129
+ budget = ExitBudget()
130
+ self._undo.append(install_exit_flush(self, budget))
131
+ if self.config.capture_logging:
132
+ self._undo.append(install_logging_capture(LogifyHandler(LEVELNO[self.config.min_level], client=self)))
133
+ if self.config.capture_uncaught:
134
+ self._undo.append(install_uncaught_capture(self, budget))
135
+
136
+ def trace(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
137
+ self.log("trace", message, exc=exc, attributes=attributes)
138
+
139
+ def debug(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
140
+ self.log("debug", message, exc=exc, attributes=attributes)
141
+
142
+ def info(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
143
+ self.log("info", message, exc=exc, attributes=attributes)
144
+
145
+ def warn(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
146
+ self.log("warn", message, exc=exc, attributes=attributes)
147
+
148
+ warning = warn
149
+
150
+ def error(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
151
+ self.log("error", message, exc=exc, attributes=attributes)
152
+
153
+ def fatal(self, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None) -> None:
154
+ self.log("fatal", message, exc=exc, attributes=attributes)
155
+
156
+ critical = fatal
157
+
158
+ def log(
159
+ self, level: str, message: object, *, exc: Optional[BaseException] = None, attributes: Optional[Mapping[str, object]] = None
160
+ ) -> None:
161
+ try:
162
+ if self._exporter is None:
163
+ return
164
+ lvl = level_from_word(level) if isinstance(level, str) else None
165
+ if lvl is None:
166
+ self.diagnostics.debug(f"unknown level {json.dumps(level) if isinstance(level, str) else repr(level)}; logged as info")
167
+ lvl = "info"
168
+ if not self._accepts(lvl):
169
+ return
170
+ body = _as_text(message)
171
+ record_attributes: dict[str, object] = {}
172
+ if isinstance(exc, BaseException):
173
+ exception = exception_attributes(exc)
174
+ record_attributes.update(exception)
175
+ if not body:
176
+ body = exception_title(exception["exception.type"], exception["exception.message"])
177
+ normalize_attributes(attributes, record_attributes)
178
+ self._emit("logify", lvl, body, record_attributes)
179
+ except Exception as err:
180
+ self.diagnostics.debug(f"dropped a record: {describe_error(err)}")
181
+
182
+ def capture_exception(self, exc: Optional[BaseException] = None, *, attributes: Optional[Mapping[str, object]] = None) -> None:
183
+ """Level error; the body is "<type>: <message>". Without exc, the exception being handled."""
184
+ try:
185
+ if self._exporter is None:
186
+ return
187
+ if exc is None:
188
+ exc = sys.exc_info()[1]
189
+ if not isinstance(exc, BaseException):
190
+ self.diagnostics.debug("capture_exception() without an exception; ignored")
191
+ return
192
+ exception = exception_attributes(exc)
193
+ record_attributes: dict[str, object] = dict(exception)
194
+ normalize_attributes(attributes, record_attributes)
195
+ self._emit("logify", "error", exception_title(exception["exception.type"], exception["exception.message"]), record_attributes)
196
+ except Exception as err:
197
+ self.diagnostics.debug(f"dropped a record: {describe_error(err)}")
198
+
199
+ def _emit(
200
+ self, scope: str, level: Optional[Level], body: str, attributes: dict[str, object], time_unix_nano: Optional[str] = None
201
+ ) -> None:
202
+ """Internal entry for captures and `logify run`: applies min_level, the
203
+ size cuts and the times, then queues the record."""
204
+ exporter = self._exporter
205
+ if exporter is None or self._closed or not self._accepts(level):
206
+ return
207
+ try:
208
+ observed = now_unix_nano()
209
+ with guarded():
210
+ exporter.enqueue(finalize_record(LogRecord(scope, time_unix_nano or observed, observed, level, body, attributes)))
211
+ except Exception as err:
212
+ self.diagnostics.debug(f"dropped a record: {describe_error(err)}")
213
+
214
+ def flush(self, timeout: float = DEFAULT_FLUSH_TIMEOUT) -> None:
215
+ try:
216
+ if self._exporter is not None:
217
+ self._exporter.flush(_valid_timeout(timeout))
218
+ except Exception as err:
219
+ self.diagnostics.debug(f"flush failed: {describe_error(err)}")
220
+
221
+ def shutdown(self, timeout: float = DEFAULT_FLUSH_TIMEOUT) -> None:
222
+ """Flushes, stops the export thread and restores everything this client installed."""
223
+ with self._lock:
224
+ if self._shut_down:
225
+ return
226
+ self._shut_down = True
227
+ self.flush(timeout)
228
+ self._closed = True
229
+ if self._exporter is not None:
230
+ self._exporter.close()
231
+ while self._undo:
232
+ undo = self._undo.pop()
233
+ try:
234
+ undo()
235
+ except Exception:
236
+ pass # restoring is best effort
237
+
238
+ def _accepts(self, level: Optional[Level]) -> bool:
239
+ return level_rank(level) >= level_rank(self.config.min_level)
dlogify/_config.py ADDED
@@ -0,0 +1,194 @@
1
+ """docs/12 §1: option > environment variable > default. Never raises for bad
2
+ values: it falls back to the default and returns a warning."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import os
8
+ import re
9
+ import urllib.parse
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, field
12
+ from typing import Optional
13
+
14
+ from ._diagnostics import is_flag
15
+ from ._levels import Level, is_level
16
+ from ._record import AttributeValue, normalize_attributes
17
+
18
+ DEFAULT_ENDPOINT = "https://api.dlogify.com"
19
+ DEFAULT_MAX_QUEUE_SIZE = 2000
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class ResolvedConfig:
24
+ api_key: str
25
+ endpoint: str
26
+ logs_url: str
27
+ service: str
28
+ environment: str
29
+ release: str
30
+ min_level: Level
31
+ capture_logging: bool
32
+ capture_uncaught: bool
33
+ attributes: dict[str, AttributeValue] = field(default_factory=dict)
34
+ max_queue_size: int = DEFAULT_MAX_QUEUE_SIZE
35
+ enabled: bool = False
36
+ debug: bool = False
37
+
38
+
39
+ def resolve_config(options: Mapping[str, object], env: Mapping[str, str], cwd: str) -> tuple[ResolvedConfig, list[str]]:
40
+ warnings: list[str] = []
41
+
42
+ def from_env(name: str) -> Optional[str]:
43
+ value = env.get(name)
44
+ value = value.strip() if isinstance(value, str) else ""
45
+ return value or None
46
+
47
+ def read(option: str, name: str) -> Optional[str]:
48
+ value = options.get(option)
49
+ if isinstance(value, str) and value.strip():
50
+ return value.strip()
51
+ return from_env(name)
52
+
53
+ def option_flag(option: str) -> Optional[bool]:
54
+ value = options.get(option)
55
+ if value is None or isinstance(value, bool):
56
+ return value
57
+ warnings.append(f"invalid {option} {value!r} (expected True or False); ignored")
58
+ return None
59
+
60
+ def flag(option: str, name: str) -> bool:
61
+ value = option_flag(option)
62
+ return value if value is not None else is_flag(env.get(name))
63
+
64
+ debug = flag("debug", "LOGIFY_DEBUG")
65
+ enabled = not flag("disabled", "LOGIFY_DISABLED")
66
+ api_key = read("api_key", "LOGIFY_API_KEY") or ""
67
+ if enabled and not api_key:
68
+ enabled = False
69
+ warnings.append("LOGIFY_API_KEY is not set; Logify is disabled")
70
+ elif enabled and not (api_key.isascii() and api_key.isprintable()):
71
+ enabled = False
72
+ warnings.append("LOGIFY_API_KEY has characters an HTTP header cannot carry; Logify is disabled")
73
+
74
+ endpoint = DEFAULT_ENDPOINT
75
+ raw_endpoint = read("endpoint", "LOGIFY_ENDPOINT")
76
+ if raw_endpoint is not None:
77
+ parsed = _parse_endpoint(raw_endpoint)
78
+ if parsed:
79
+ endpoint = parsed
80
+ else:
81
+ warnings.append(f"invalid endpoint {json.dumps(raw_endpoint)}; using {DEFAULT_ENDPOINT}")
82
+
83
+ min_level: Level = "info"
84
+ raw_level = read("min_level", "LOGIFY_MIN_LEVEL")
85
+ if raw_level is not None:
86
+ if is_level(raw_level.lower()):
87
+ min_level = raw_level.lower() # type: ignore[assignment]
88
+ else:
89
+ warnings.append(f"unknown level {json.dumps(raw_level.lower())}; using info")
90
+
91
+ max_queue_size = DEFAULT_MAX_QUEUE_SIZE
92
+ raw_size = options.get("max_queue_size")
93
+ if raw_size is not None:
94
+ if isinstance(raw_size, int) and not isinstance(raw_size, bool) and raw_size > 0:
95
+ max_queue_size = raw_size
96
+ else:
97
+ warnings.append(f"invalid max_queue_size {raw_size!r}; using {DEFAULT_MAX_QUEUE_SIZE}")
98
+
99
+ config = ResolvedConfig(
100
+ api_key=api_key,
101
+ endpoint=endpoint,
102
+ logs_url=f"{endpoint}/v1/logs",
103
+ service=read("service", "LOGIFY_SERVICE") or from_env("OTEL_SERVICE_NAME") or project_name(cwd) or "unknown_service",
104
+ environment=read("environment", "LOGIFY_ENVIRONMENT") or "default",
105
+ release=read("release", "LOGIFY_RELEASE") or "",
106
+ min_level=min_level,
107
+ capture_logging=option_flag("capture_logging") is not False,
108
+ capture_uncaught=option_flag("capture_uncaught") is not False,
109
+ attributes=normalize_attributes(options.get("attributes")),
110
+ max_queue_size=max_queue_size,
111
+ enabled=enabled,
112
+ debug=debug,
113
+ )
114
+ return config, warnings
115
+
116
+
117
+ def disabled_config() -> ResolvedConfig:
118
+ """Used when resolution itself fails."""
119
+ return ResolvedConfig(
120
+ api_key="",
121
+ endpoint=DEFAULT_ENDPOINT,
122
+ logs_url=f"{DEFAULT_ENDPOINT}/v1/logs",
123
+ service="unknown_service",
124
+ environment="default",
125
+ release="",
126
+ min_level="info",
127
+ capture_logging=False,
128
+ capture_uncaught=False,
129
+ )
130
+
131
+
132
+ def _parse_endpoint(raw: str) -> Optional[str]:
133
+ if any(char.isspace() for char in raw):
134
+ return None
135
+ try:
136
+ parts = urllib.parse.urlsplit(raw)
137
+ parts.port # raises ValueError for a port out of range
138
+ except ValueError:
139
+ return None
140
+ if parts.scheme.lower() not in ("http", "https") or not parts.hostname or parts.query or parts.fragment:
141
+ return None
142
+ if "?" in raw or "#" in raw:
143
+ return None
144
+ return raw.rstrip("/")
145
+
146
+
147
+ def project_name(start: str) -> Optional[str]:
148
+ """The [project] name of the nearest pyproject.toml, walking up from start."""
149
+ directory = os.path.abspath(start)
150
+ while True:
151
+ try:
152
+ with open(os.path.join(directory, "pyproject.toml"), "rb") as file:
153
+ data = file.read()
154
+ except OSError:
155
+ data = None
156
+ if data is not None:
157
+ return _project_name_from_toml(data.decode("utf-8", "replace"))
158
+ parent = os.path.dirname(directory)
159
+ if parent == directory:
160
+ return None
161
+ directory = parent
162
+
163
+
164
+ def _project_name_from_toml(text: str) -> Optional[str]:
165
+ try:
166
+ import tomllib
167
+ except ImportError: # Python 3.10
168
+ return scan_project_name(text)
169
+ try:
170
+ name = tomllib.loads(text).get("project", {}).get("name")
171
+ except Exception:
172
+ return None
173
+ return name if isinstance(name, str) and name else None
174
+
175
+
176
+ _TABLE = re.compile(r"^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$")
177
+ _NAME = re.compile(r"""^\s*name\s*=\s*(?:"([^"\\]*)"|'([^']*)')\s*(?:#.*)?$""")
178
+
179
+
180
+ def scan_project_name(text: str) -> Optional[str]:
181
+ """A minimal reader for Python 3.10, which has no tomllib: the `name` key of
182
+ the [project] table, as a basic or literal string on one line."""
183
+ in_project = False
184
+ for line in text.splitlines():
185
+ table = _TABLE.match(line)
186
+ if table:
187
+ in_project = table.group(1) == "project"
188
+ continue
189
+ if in_project:
190
+ match = _NAME.match(line)
191
+ if match:
192
+ name = match.group(1) if match.group(1) is not None else match.group(2)
193
+ return name or None
194
+ return None