Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,5 +15,5 @@

## Scope

<!-- One phase/concern per PR where possible. Call out here if this
<!-- One concern per PR where possible. Call out here if this
intentionally spans more than one. -->
18 changes: 13 additions & 5 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,14 @@ All notable changes to this project are documented in this file.

## Unreleased

- Plugin pipeline: `Plugin` base (`before_log`/`after_log`/`on_error`,
all optional to override), `ContextPlugin` (merges fixed context into
`meta`), `RedactPlugin` (replaces sensitive `meta` values by key, case-
insensitive), and `SamplingPlugin` (probabilistically drops records).
`Logger` now accepts `plugins=[...]` and gained `.use(plugin)` to register
one and chain. A plugin hook that raises is caught, routed to that same
plugin's `on_error`, and the pipeline continues — a broken plugin can't
crash logging, verified by test.
- Added `.github/dependabot.yml`: weekly version updates for `pip`
dependencies and GitHub Actions.
- Added GitHub issue templates: `.github/ISSUE_TEMPLATE/bug_report.yml`,
Expand All@@ -12,23 +20,23 @@ All notable changes to this project are documented in this file.
- Added `.github/SECURITY.md`: supported-versions policy and instructions
to report vulnerabilities via GitHub's private vulnerability reporting
instead of public issues. Linked from the README.
- Phase 2 transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
- Transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
(colorized, ERROR/FATAL to stderr), `FileTransport` (size-based rotation), and
`HTTPTransport` (batched, newline-delimited JSON over stdlib `urllib`, with an
injectable `sender` for tests or alternate backends). `Logger` now accepts
`transports=[...]` and dispatches each record to them synchronously, and gained
`.close()` to close all attached transports. Dispatch is still synchronous —
the non-blocking queue/async path is Phase 4. Also added `CollectingTransport`,
an in-memory transport for tests.
a non-blocking queue/async path isn't implemented yet. Also added
`CollectingTransport`, an in-memory transport for tests.
- Added `CODE_OF_CONDUCT.md` (Contributor Covenant v2.1), `.github/CODEOWNERS`,
`.github/PULL_REQUEST_TEMPLATE.md`, and `CONTRIBUTING.md` documenting the
PR workflow (branch naming, scoping, review/CI requirements, squash-merge).
- Phase 1 core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
- Core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
logquill-js's numeric weights), `parse_level()`, the `LogRecord` shape,
`Logger` with `.trace()/.debug()/.info()/.warn()/.error()/.fatal()` and
`.set_level()`, and a `Formatter` protocol with a `JSONFormatter`
implementation. Log calls return the record dict (or `None` when filtered
by level) — no transports or dispatch yet, that's Phase 2.
by level) — no transports or dispatch yet.
- Repo scaffold: `pyproject.toml`, package skeleton, dev tooling (ruff, mypy --strict, pytest), pre-commit hooks, and CI workflow.
- Packaging metadata: expanded classifiers (OS, Topic) and keywords, added an `Issues` project URL, and fixed the `Homepage`/`Repository`/`Changelog` URLs to point at the actual `nikhilvdev/logquill-python` GitHub repo instead of a stale placeholder org.
- Added a pepy.tech download-count badge to the README for tracking installs.
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,8 @@ pytest

- **Branch from `main`**, name branches by intent: `feat/…`, `fix/…`,
`docs/…`, `chore/…` (e.g. `feat/rotating-file-transport`).
- **Keep PRs scoped to one phase or concern** where possible. A PR that
mixes an unrelated refactor with a feature is harder to review and harder
to revert.
- **Keep PRs scoped to one concern** where possible. A PR that mixes an
unrelated refactor with a feature is harder to review and harder to revert.
- **Every PR must satisfy this definition of done** before it's ready for
review:
1. Type hints throughout, `mypy --strict` clean on the public API
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,18 +14,20 @@ Sibling to [`logquill` on npm](https://www.npmjs.com/package/logquill)
across a Python + Node stack.

Status: pre-release, under active development. The core `Logger`, level
filtering, and transports are implemented; plugins and non-blocking async
dispatch are not yet — see `CHANGELOG.md` for what's landed so far.
filtering, transports, and the plugin pipeline are implemented;
non-blocking async dispatch is not yet — see `CHANGELOG.md` for what's
landed so far.

## Features

- **Structured by default** — every call carries a `meta` dict, not just a message string
- **Cross-language record shape** — identical JSON shape and level names/weights as [`logquill` on npm](https://www.npmjs.com/package/logquill)
- **Pluggable transports** — `ConsoleTransport` (colorized, stderr for errors), `FileTransport` (rotation), `HTTPTransport` (batched); write your own by subclassing `Transport`
- **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> str` for your own
- **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `SamplingPlugin` out of the box; a broken plugin can't crash logging
- **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP
- **Typed throughout** — `mypy --strict` clean on the public API
- *(planned)* plugin pipeline (redaction, sampling, rate limiting), non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`
- *(planned)* non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`

## Install

Expand DownExpand Up@@ -66,7 +68,7 @@ print(JSONFormatter().format(record))

Attach transports to a `Logger` to actually write records somewhere. Each
record is dispatched to every attached transport synchronously (non-blocking
dispatch lands in a later phase):
dispatch isn't implemented yet):

```python
from logquill import ConsoleTransport, FileTransport, HTTPTransport, Logger
Expand DownExpand Up@@ -99,6 +101,28 @@ logger.info("hello")
assert sink.records[0]["message"] == "hello"
```

## Plugins

Plugins hook into the pipeline around each log call: `before_log(record)` can
transform a record or return `None` to drop it, `after_log(record)` runs once
it's been dispatched to every transport, and `on_error(exc, record)` catches
anything a plugin's own hooks raise — a broken plugin can't take down logging.

```python
from logquill import ContextPlugin, Logger, RedactPlugin, SamplingPlugin

logger = Logger("app")
logger.use(ContextPlugin(service="api", env="prod")) # merged into every record's meta
logger.use(RedactPlugin(keys=["password", "token"])) # replaces matching meta values
logger.use(SamplingPlugin(0.1)) # keep ~10% of records that reach this point

logger.info("login attempt", user_id=42, password="hunter2")
# meta: {'service': 'api', 'env': 'prod', 'user_id': 42, 'password': '***'}
# (unless this call was one of the ~90% sampling dropped, in which case it's None)
```

Write your own by subclassing `Plugin`; override only the hooks you need.

## Development

```bash
Expand Down
10 changes: 9 additions & 1 deletion logquill/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
from logquill.console_transport import ConsoleTransport
from logquill.context_plugin import ContextPlugin
from logquill.file_transport import FileTransport
from logquill.formatter import Formatter, JSONFormatter
from logquill.http_transport import HTTPTransport
from logquill.levels import Level, parse_level
from logquill.logger import Logger
from logquill.plugin import Plugin
from logquill.records import LogRecord
from logquill.redact_plugin import RedactPlugin
from logquill.sampling_plugin import SamplingPlugin
from logquill.transport import CollectingTransport, Transport

__version__ = "0.1.2"
__version__ = "0.1.3"

__all__ = [
"CollectingTransport",
"ConsoleTransport",
"ContextPlugin",
"FileTransport",
"Formatter",
"HTTPTransport",
"JSONFormatter",
"Level",
"LogRecord",
"Logger",
"Plugin",
"RedactPlugin",
"SamplingPlugin",
"Transport",
"parse_level",
"__version__",
Expand Down
20 changes: 20 additions & 0 deletions logquill/context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from typing import Any

from logquill.plugin import Plugin
from logquill.records import LogRecord


class ContextPlugin(Plugin):
"""Injects fixed key/value pairs into every record's `meta`.

A value already present in a record's own `meta` wins over the fixed context.
"""

def __init__(self, **context: Any) -> None:
self.context = context

def before_log(self, record: LogRecord) -> LogRecord | None:
record["meta"] = {**self.context, **record["meta"]}
return record
2 changes: 1 addition & 1 deletion logquill/http_transport.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@ class HTTPTransport(Transport):

Uses `urllib` (stdlib) by default so the core package stays dependency-free.
Pass `sender` to swap in a fake for tests, or a different backend (e.g. an
aiohttp-based one, once the async dispatch path from Phase 4 exists).
aiohttp-based one, once a non-blocking async dispatch path exists).
"""

def __init__(
Expand Down
32 changes: 32 additions & 0 deletions logquill/logger.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import contextlib
from typing import Any

from logquill.levels import Level, parse_level
from logquill.plugin import Plugin
from logquill.records import LogRecord, create_record
from logquill.transport import Transport

Expand All@@ -13,10 +15,12 @@ def __init__(
name: str,
level: int | str | Level = Level.INFO,
transports: list[Transport] | None = None,
plugins: list[Plugin] | None = None,
) -> None:
self.name = name
self._level = parse_level(level)
self.transports: list[Transport] = list(transports) if transports else []
self.plugins: list[Plugin] = list(plugins) if plugins else []

@property
def level(self) -> Level:
Expand All@@ -25,17 +29,45 @@ def level(self) -> Level:
def set_level(self, level: int | str | Level) -> None:
self._level = parse_level(level)

def use(self, plugin: Plugin) -> Logger:
"""Register a plugin. Returns `self` so calls can be chained."""
self.plugins.append(plugin)
return self

def close(self) -> None:
"""Close every attached transport. Call on shutdown to flush buffered writes."""
for transport in self.transports:
transport.close()

def _notify_error(self, plugin: Plugin, exc: Exception, record: LogRecord) -> None:
# a broken error handler must not crash logging either
with contextlib.suppress(Exception):
plugin.on_error(exc, record)

def _log(self, level: Level, message: str, meta: dict[str, Any]) -> LogRecord | None:
if level < self._level:
return None
record = create_record(level=level, logger=self.name, message=message, meta=meta)

for plugin in self.plugins:
try:
result = plugin.before_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)
continue
if result is None:
return None
record = result

for transport in self.transports:
transport.write(transport.format(record), record)

for plugin in self.plugins:
try:
plugin.after_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)

return record

def trace(self, message: str, **meta: Any) -> LogRecord | None:
Expand Down
22 changes: 22 additions & 0 deletions logquill/plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from logquill.records import LogRecord


class Plugin:
"""Base class for the plugin pipeline: `before_log`, `after_log`, `on_error`.

Override only the hooks you need — the rest default to no-ops. A plugin
hook that raises cannot crash logging: the pipeline catches it, routes it
to `on_error`, and moves on.
"""

def before_log(self, record: LogRecord) -> LogRecord | None:
"""Return a (possibly modified) record, or `None` to drop it."""
return record

def after_log(self, record: LogRecord) -> None:
"""Called after the record has been dispatched to every transport."""

def on_error(self, exc: Exception, record: LogRecord) -> None:
"""Called when one of this plugin's own hooks raises."""
28 changes: 28 additions & 0 deletions logquill/redact_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from collections.abc import Iterable

from logquill.plugin import Plugin
from logquill.records import LogRecord

DEFAULT_REDACTED_KEYS = frozenset({"password", "token", "secret", "api_key", "authorization"})


class RedactPlugin(Plugin):
"""Replaces sensitive `meta` values, matched by key (case-insensitive), with a placeholder."""

def __init__(
self,
keys: Iterable[str] = DEFAULT_REDACTED_KEYS,
replacement: str = "***",
) -> None:
self.keys = {key.lower() for key in keys}
self.replacement = replacement

def before_log(self, record: LogRecord) -> LogRecord | None:
meta = record["meta"]
record["meta"] = {
key: self.replacement if key.lower() in self.keys else value
for key, value in meta.items()
}
return record
20 changes: 20 additions & 0 deletions logquill/sampling_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

import random
from typing import Callable

from logquill.plugin import Plugin
from logquill.records import LogRecord


class SamplingPlugin(Plugin):
"""Keeps roughly `rate` of records (0.0-1.0), dropping the rest."""

def __init__(self, rate: float, rng: Callable[[], float] | None = None) -> None:
if not 0.0 <= rate <= 1.0:
raise ValueError(f"rate must be between 0 and 1, got {rate!r}")
self.rate = rate
self._rng = rng or random.random

def before_log(self, record: LogRecord) -> LogRecord | None:
return record if self._rng() < self.rate else None
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "logquill"
version = "0.1.2"
version = "0.1.3"
description = "A structured, leveled logging framework with pluggable transports and a plugin pipeline."
readme = "README.md"
license = "MIT"
Expand Down
20 changes: 20 additions & 0 deletions tests/test_context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from logquill.context_plugin import ContextPlugin
from logquill.logger import Logger


def test_injects_fixed_context_into_meta() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(service="api", env="prod")])

record = logger.info("hello", user_id=42)

assert record is not None
assert record["meta"] == {"service": "api", "env": "prod", "user_id": 42}


def test_call_site_meta_overrides_fixed_context() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(env="prod")])

record = logger.info("hello", env="staging")

assert record is not None
assert record["meta"]["env"] == "staging"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,5 +15,5 @@

## Scope

<!-- One phase/concern per PR where possible. Call out here if this
<!-- One concern per PR where possible. Call out here if this
intentionally spans more than one. -->
18 changes: 13 additions & 5 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,14 @@ All notable changes to this project are documented in this file.

## Unreleased

- Plugin pipeline: `Plugin` base (`before_log`/`after_log`/`on_error`,
all optional to override), `ContextPlugin` (merges fixed context into
`meta`), `RedactPlugin` (replaces sensitive `meta` values by key, case-
insensitive), and `SamplingPlugin` (probabilistically drops records).
`Logger` now accepts `plugins=[...]` and gained `.use(plugin)` to register
one and chain. A plugin hook that raises is caught, routed to that same
plugin's `on_error`, and the pipeline continues — a broken plugin can't
crash logging, verified by test.
- Added `.github/dependabot.yml`: weekly version updates for `pip`
dependencies and GitHub Actions.
- Added GitHub issue templates: `.github/ISSUE_TEMPLATE/bug_report.yml`,
Expand All@@ -12,23 +20,23 @@ All notable changes to this project are documented in this file.
- Added `.github/SECURITY.md`: supported-versions policy and instructions
to report vulnerabilities via GitHub's private vulnerability reporting
instead of public issues. Linked from the README.
- Phase 2 transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
- Transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
(colorized, ERROR/FATAL to stderr), `FileTransport` (size-based rotation), and
`HTTPTransport` (batched, newline-delimited JSON over stdlib `urllib`, with an
injectable `sender` for tests or alternate backends). `Logger` now accepts
`transports=[...]` and dispatches each record to them synchronously, and gained
`.close()` to close all attached transports. Dispatch is still synchronous —
the non-blocking queue/async path is Phase 4. Also added `CollectingTransport`,
an in-memory transport for tests.
a non-blocking queue/async path isn't implemented yet. Also added
`CollectingTransport`, an in-memory transport for tests.
- Added `CODE_OF_CONDUCT.md` (Contributor Covenant v2.1), `.github/CODEOWNERS`,
`.github/PULL_REQUEST_TEMPLATE.md`, and `CONTRIBUTING.md` documenting the
PR workflow (branch naming, scoping, review/CI requirements, squash-merge).
- Phase 1 core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
- Core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
logquill-js's numeric weights), `parse_level()`, the `LogRecord` shape,
`Logger` with `.trace()/.debug()/.info()/.warn()/.error()/.fatal()` and
`.set_level()`, and a `Formatter` protocol with a `JSONFormatter`
implementation. Log calls return the record dict (or `None` when filtered
by level) — no transports or dispatch yet, that's Phase 2.
by level) — no transports or dispatch yet.
- Repo scaffold: `pyproject.toml`, package skeleton, dev tooling (ruff, mypy --strict, pytest), pre-commit hooks, and CI workflow.
- Packaging metadata: expanded classifiers (OS, Topic) and keywords, added an `Issues` project URL, and fixed the `Homepage`/`Repository`/`Changelog` URLs to point at the actual `nikhilvdev/logquill-python` GitHub repo instead of a stale placeholder org.
- Added a pepy.tech download-count badge to the README for tracking installs.
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,8 @@ pytest

- **Branch from `main`**, name branches by intent: `feat/…`, `fix/…`,
`docs/…`, `chore/…` (e.g. `feat/rotating-file-transport`).
- **Keep PRs scoped to one phase or concern** where possible. A PR that
mixes an unrelated refactor with a feature is harder to review and harder
to revert.
- **Keep PRs scoped to one concern** where possible. A PR that mixes an
unrelated refactor with a feature is harder to review and harder to revert.
- **Every PR must satisfy this definition of done** before it's ready for
review:
1. Type hints throughout, `mypy --strict` clean on the public API
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,18 +14,20 @@ Sibling to [`logquill` on npm](https://www.npmjs.com/package/logquill)
across a Python + Node stack.

Status: pre-release, under active development. The core `Logger`, level
filtering, and transports are implemented; plugins and non-blocking async
dispatch are not yet — see `CHANGELOG.md` for what's landed so far.
filtering, transports, and the plugin pipeline are implemented;
non-blocking async dispatch is not yet — see `CHANGELOG.md` for what's
landed so far.

## Features

- **Structured by default** — every call carries a `meta` dict, not just a message string
- **Cross-language record shape** — identical JSON shape and level names/weights as [`logquill` on npm](https://www.npmjs.com/package/logquill)
- **Pluggable transports** — `ConsoleTransport` (colorized, stderr for errors), `FileTransport` (rotation), `HTTPTransport` (batched); write your own by subclassing `Transport`
- **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> str` for your own
- **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `SamplingPlugin` out of the box; a broken plugin can't crash logging
- **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP
- **Typed throughout** — `mypy --strict` clean on the public API
- *(planned)* plugin pipeline (redaction, sampling, rate limiting), non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`
- *(planned)* non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`

## Install

Expand DownExpand Up@@ -66,7 +68,7 @@ print(JSONFormatter().format(record))

Attach transports to a `Logger` to actually write records somewhere. Each
record is dispatched to every attached transport synchronously (non-blocking
dispatch lands in a later phase):
dispatch isn't implemented yet):

```python
from logquill import ConsoleTransport, FileTransport, HTTPTransport, Logger
Expand DownExpand Up@@ -99,6 +101,28 @@ logger.info("hello")
assert sink.records[0]["message"] == "hello"
```

## Plugins

Plugins hook into the pipeline around each log call: `before_log(record)` can
transform a record or return `None` to drop it, `after_log(record)` runs once
it's been dispatched to every transport, and `on_error(exc, record)` catches
anything a plugin's own hooks raise — a broken plugin can't take down logging.

```python
from logquill import ContextPlugin, Logger, RedactPlugin, SamplingPlugin

logger = Logger("app")
logger.use(ContextPlugin(service="api", env="prod")) # merged into every record's meta
logger.use(RedactPlugin(keys=["password", "token"])) # replaces matching meta values
logger.use(SamplingPlugin(0.1)) # keep ~10% of records that reach this point

logger.info("login attempt", user_id=42, password="hunter2")
# meta: {'service': 'api', 'env': 'prod', 'user_id': 42, 'password': '***'}
# (unless this call was one of the ~90% sampling dropped, in which case it's None)
```

Write your own by subclassing `Plugin`; override only the hooks you need.

## Development

```bash
Expand Down
10 changes: 9 additions & 1 deletion logquill/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
from logquill.console_transport import ConsoleTransport
from logquill.context_plugin import ContextPlugin
from logquill.file_transport import FileTransport
from logquill.formatter import Formatter, JSONFormatter
from logquill.http_transport import HTTPTransport
from logquill.levels import Level, parse_level
from logquill.logger import Logger
from logquill.plugin import Plugin
from logquill.records import LogRecord
from logquill.redact_plugin import RedactPlugin
from logquill.sampling_plugin import SamplingPlugin
from logquill.transport import CollectingTransport, Transport

__version__ = "0.1.2"
__version__ = "0.1.3"

__all__ = [
"CollectingTransport",
"ConsoleTransport",
"ContextPlugin",
"FileTransport",
"Formatter",
"HTTPTransport",
"JSONFormatter",
"Level",
"LogRecord",
"Logger",
"Plugin",
"RedactPlugin",
"SamplingPlugin",
"Transport",
"parse_level",
"__version__",
Expand Down
20 changes: 20 additions & 0 deletions logquill/context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from typing import Any

from logquill.plugin import Plugin
from logquill.records import LogRecord


class ContextPlugin(Plugin):
"""Injects fixed key/value pairs into every record's `meta`.

A value already present in a record's own `meta` wins over the fixed context.
"""

def __init__(self, **context: Any) -> None:
self.context = context

def before_log(self, record: LogRecord) -> LogRecord | None:
record["meta"] = {**self.context, **record["meta"]}
return record
2 changes: 1 addition & 1 deletion logquill/http_transport.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@ class HTTPTransport(Transport):

Uses `urllib` (stdlib) by default so the core package stays dependency-free.
Pass `sender` to swap in a fake for tests, or a different backend (e.g. an
aiohttp-based one, once the async dispatch path from Phase 4 exists).
aiohttp-based one, once a non-blocking async dispatch path exists).
"""

def __init__(
Expand Down
32 changes: 32 additions & 0 deletions logquill/logger.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import contextlib
from typing import Any

from logquill.levels import Level, parse_level
from logquill.plugin import Plugin
from logquill.records import LogRecord, create_record
from logquill.transport import Transport

Expand All@@ -13,10 +15,12 @@ def __init__(
name: str,
level: int | str | Level = Level.INFO,
transports: list[Transport] | None = None,
plugins: list[Plugin] | None = None,
) -> None:
self.name = name
self._level = parse_level(level)
self.transports: list[Transport] = list(transports) if transports else []
self.plugins: list[Plugin] = list(plugins) if plugins else []

@property
def level(self) -> Level:
Expand All@@ -25,17 +29,45 @@ def level(self) -> Level:
def set_level(self, level: int | str | Level) -> None:
self._level = parse_level(level)

def use(self, plugin: Plugin) -> Logger:
"""Register a plugin. Returns `self` so calls can be chained."""
self.plugins.append(plugin)
return self

def close(self) -> None:
"""Close every attached transport. Call on shutdown to flush buffered writes."""
for transport in self.transports:
transport.close()

def _notify_error(self, plugin: Plugin, exc: Exception, record: LogRecord) -> None:
# a broken error handler must not crash logging either
with contextlib.suppress(Exception):
plugin.on_error(exc, record)

def _log(self, level: Level, message: str, meta: dict[str, Any]) -> LogRecord | None:
if level < self._level:
return None
record = create_record(level=level, logger=self.name, message=message, meta=meta)

for plugin in self.plugins:
try:
result = plugin.before_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)
continue
if result is None:
return None
record = result

for transport in self.transports:
transport.write(transport.format(record), record)

for plugin in self.plugins:
try:
plugin.after_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)

return record

def trace(self, message: str, **meta: Any) -> LogRecord | None:
Expand Down
22 changes: 22 additions & 0 deletions logquill/plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from logquill.records import LogRecord


class Plugin:
"""Base class for the plugin pipeline: `before_log`, `after_log`, `on_error`.

Override only the hooks you need — the rest default to no-ops. A plugin
hook that raises cannot crash logging: the pipeline catches it, routes it
to `on_error`, and moves on.
"""

def before_log(self, record: LogRecord) -> LogRecord | None:
"""Return a (possibly modified) record, or `None` to drop it."""
return record

def after_log(self, record: LogRecord) -> None:
"""Called after the record has been dispatched to every transport."""

def on_error(self, exc: Exception, record: LogRecord) -> None:
"""Called when one of this plugin's own hooks raises."""
28 changes: 28 additions & 0 deletions logquill/redact_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from collections.abc import Iterable

from logquill.plugin import Plugin
from logquill.records import LogRecord

DEFAULT_REDACTED_KEYS = frozenset({"password", "token", "secret", "api_key", "authorization"})


class RedactPlugin(Plugin):
"""Replaces sensitive `meta` values, matched by key (case-insensitive), with a placeholder."""

def __init__(
self,
keys: Iterable[str] = DEFAULT_REDACTED_KEYS,
replacement: str = "***",
) -> None:
self.keys = {key.lower() for key in keys}
self.replacement = replacement

def before_log(self, record: LogRecord) -> LogRecord | None:
meta = record["meta"]
record["meta"] = {
key: self.replacement if key.lower() in self.keys else value
for key, value in meta.items()
}
return record
20 changes: 20 additions & 0 deletions logquill/sampling_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

import random
from typing import Callable

from logquill.plugin import Plugin
from logquill.records import LogRecord


class SamplingPlugin(Plugin):
"""Keeps roughly `rate` of records (0.0-1.0), dropping the rest."""

def __init__(self, rate: float, rng: Callable[[], float] | None = None) -> None:
if not 0.0 <= rate <= 1.0:
raise ValueError(f"rate must be between 0 and 1, got {rate!r}")
self.rate = rate
self._rng = rng or random.random

def before_log(self, record: LogRecord) -> LogRecord | None:
return record if self._rng() < self.rate else None
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "logquill"
version = "0.1.2"
version = "0.1.3"
description = "A structured, leveled logging framework with pluggable transports and a plugin pipeline."
readme = "README.md"
license = "MIT"
Expand Down
20 changes: 20 additions & 0 deletions tests/test_context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from logquill.context_plugin import ContextPlugin
from logquill.logger import Logger


def test_injects_fixed_context_into_meta() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(service="api", env="prod")])

record = logger.info("hello", user_id=42)

assert record is not None
assert record["meta"] == {"service": "api", "env": "prod", "user_id": 42}


def test_call_site_meta_overrides_fixed_context() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(env="prod")])

record = logger.info("hello", env="staging")

assert record is not None
assert record["meta"]["env"] == "staging"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,5 +15,5 @@

## Scope

<!-- One phase/concern per PR where possible. Call out here if this
<!-- One concern per PR where possible. Call out here if this
intentionally spans more than one. -->
18 changes: 13 additions & 5 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,14 @@ All notable changes to this project are documented in this file.

## Unreleased

- Plugin pipeline: `Plugin` base (`before_log`/`after_log`/`on_error`,
all optional to override), `ContextPlugin` (merges fixed context into
`meta`), `RedactPlugin` (replaces sensitive `meta` values by key, case-
insensitive), and `SamplingPlugin` (probabilistically drops records).
`Logger` now accepts `plugins=[...]` and gained `.use(plugin)` to register
one and chain. A plugin hook that raises is caught, routed to that same
plugin's `on_error`, and the pipeline continues — a broken plugin can't
crash logging, verified by test.
- Added `.github/dependabot.yml`: weekly version updates for `pip`
dependencies and GitHub Actions.
- Added GitHub issue templates: `.github/ISSUE_TEMPLATE/bug_report.yml`,
Expand All@@ -12,23 +20,23 @@ All notable changes to this project are documented in this file.
- Added `.github/SECURITY.md`: supported-versions policy and instructions
to report vulnerabilities via GitHub's private vulnerability reporting
instead of public issues. Linked from the README.
- Phase 2 transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
- Transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
(colorized, ERROR/FATAL to stderr), `FileTransport` (size-based rotation), and
`HTTPTransport` (batched, newline-delimited JSON over stdlib `urllib`, with an
injectable `sender` for tests or alternate backends). `Logger` now accepts
`transports=[...]` and dispatches each record to them synchronously, and gained
`.close()` to close all attached transports. Dispatch is still synchronous —
the non-blocking queue/async path is Phase 4. Also added `CollectingTransport`,
an in-memory transport for tests.
a non-blocking queue/async path isn't implemented yet. Also added
`CollectingTransport`, an in-memory transport for tests.
- Added `CODE_OF_CONDUCT.md` (Contributor Covenant v2.1), `.github/CODEOWNERS`,
`.github/PULL_REQUEST_TEMPLATE.md`, and `CONTRIBUTING.md` documenting the
PR workflow (branch naming, scoping, review/CI requirements, squash-merge).
- Phase 1 core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
- Core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
logquill-js's numeric weights), `parse_level()`, the `LogRecord` shape,
`Logger` with `.trace()/.debug()/.info()/.warn()/.error()/.fatal()` and
`.set_level()`, and a `Formatter` protocol with a `JSONFormatter`
implementation. Log calls return the record dict (or `None` when filtered
by level) — no transports or dispatch yet, that's Phase 2.
by level) — no transports or dispatch yet.
- Repo scaffold: `pyproject.toml`, package skeleton, dev tooling (ruff, mypy --strict, pytest), pre-commit hooks, and CI workflow.
- Packaging metadata: expanded classifiers (OS, Topic) and keywords, added an `Issues` project URL, and fixed the `Homepage`/`Repository`/`Changelog` URLs to point at the actual `nikhilvdev/logquill-python` GitHub repo instead of a stale placeholder org.
- Added a pepy.tech download-count badge to the README for tracking installs.
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,8 @@ pytest

- **Branch from `main`**, name branches by intent: `feat/…`, `fix/…`,
`docs/…`, `chore/…` (e.g. `feat/rotating-file-transport`).
- **Keep PRs scoped to one phase or concern** where possible. A PR that
mixes an unrelated refactor with a feature is harder to review and harder
to revert.
- **Keep PRs scoped to one concern** where possible. A PR that mixes an
unrelated refactor with a feature is harder to review and harder to revert.
- **Every PR must satisfy this definition of done** before it's ready for
review:
1. Type hints throughout, `mypy --strict` clean on the public API
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,18 +14,20 @@ Sibling to [`logquill` on npm](https://www.npmjs.com/package/logquill)
across a Python + Node stack.

Status: pre-release, under active development. The core `Logger`, level
filtering, and transports are implemented; plugins and non-blocking async
dispatch are not yet — see `CHANGELOG.md` for what's landed so far.
filtering, transports, and the plugin pipeline are implemented;
non-blocking async dispatch is not yet — see `CHANGELOG.md` for what's
landed so far.

## Features

- **Structured by default** — every call carries a `meta` dict, not just a message string
- **Cross-language record shape** — identical JSON shape and level names/weights as [`logquill` on npm](https://www.npmjs.com/package/logquill)
- **Pluggable transports** — `ConsoleTransport` (colorized, stderr for errors), `FileTransport` (rotation), `HTTPTransport` (batched); write your own by subclassing `Transport`
- **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> str` for your own
- **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `SamplingPlugin` out of the box; a broken plugin can't crash logging
- **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP
- **Typed throughout** — `mypy --strict` clean on the public API
- *(planned)* plugin pipeline (redaction, sampling, rate limiting), non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`
- *(planned)* non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`

## Install

Expand DownExpand Up@@ -66,7 +68,7 @@ print(JSONFormatter().format(record))

Attach transports to a `Logger` to actually write records somewhere. Each
record is dispatched to every attached transport synchronously (non-blocking
dispatch lands in a later phase):
dispatch isn't implemented yet):

```python
from logquill import ConsoleTransport, FileTransport, HTTPTransport, Logger
Expand DownExpand Up@@ -99,6 +101,28 @@ logger.info("hello")
assert sink.records[0]["message"] == "hello"
```

## Plugins

Plugins hook into the pipeline around each log call: `before_log(record)` can
transform a record or return `None` to drop it, `after_log(record)` runs once
it's been dispatched to every transport, and `on_error(exc, record)` catches
anything a plugin's own hooks raise — a broken plugin can't take down logging.

```python
from logquill import ContextPlugin, Logger, RedactPlugin, SamplingPlugin

logger = Logger("app")
logger.use(ContextPlugin(service="api", env="prod")) # merged into every record's meta
logger.use(RedactPlugin(keys=["password", "token"])) # replaces matching meta values
logger.use(SamplingPlugin(0.1)) # keep ~10% of records that reach this point

logger.info("login attempt", user_id=42, password="hunter2")
# meta: {'service': 'api', 'env': 'prod', 'user_id': 42, 'password': '***'}
# (unless this call was one of the ~90% sampling dropped, in which case it's None)
```

Write your own by subclassing `Plugin`; override only the hooks you need.

## Development

```bash
Expand Down
10 changes: 9 additions & 1 deletion logquill/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
from logquill.console_transport import ConsoleTransport
from logquill.context_plugin import ContextPlugin
from logquill.file_transport import FileTransport
from logquill.formatter import Formatter, JSONFormatter
from logquill.http_transport import HTTPTransport
from logquill.levels import Level, parse_level
from logquill.logger import Logger
from logquill.plugin import Plugin
from logquill.records import LogRecord
from logquill.redact_plugin import RedactPlugin
from logquill.sampling_plugin import SamplingPlugin
from logquill.transport import CollectingTransport, Transport

__version__ = "0.1.2"
__version__ = "0.1.3"

__all__ = [
"CollectingTransport",
"ConsoleTransport",
"ContextPlugin",
"FileTransport",
"Formatter",
"HTTPTransport",
"JSONFormatter",
"Level",
"LogRecord",
"Logger",
"Plugin",
"RedactPlugin",
"SamplingPlugin",
"Transport",
"parse_level",
"__version__",
Expand Down
20 changes: 20 additions & 0 deletions logquill/context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from typing import Any

from logquill.plugin import Plugin
from logquill.records import LogRecord


class ContextPlugin(Plugin):
"""Injects fixed key/value pairs into every record's `meta`.

A value already present in a record's own `meta` wins over the fixed context.
"""

def __init__(self, **context: Any) -> None:
self.context = context

def before_log(self, record: LogRecord) -> LogRecord | None:
record["meta"] = {**self.context, **record["meta"]}
return record
2 changes: 1 addition & 1 deletion logquill/http_transport.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@ class HTTPTransport(Transport):

Uses `urllib` (stdlib) by default so the core package stays dependency-free.
Pass `sender` to swap in a fake for tests, or a different backend (e.g. an
aiohttp-based one, once the async dispatch path from Phase 4 exists).
aiohttp-based one, once a non-blocking async dispatch path exists).
"""

def __init__(
Expand Down
32 changes: 32 additions & 0 deletions logquill/logger.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import contextlib
from typing import Any

from logquill.levels import Level, parse_level
from logquill.plugin import Plugin
from logquill.records import LogRecord, create_record
from logquill.transport import Transport

Expand All@@ -13,10 +15,12 @@ def __init__(
name: str,
level: int | str | Level = Level.INFO,
transports: list[Transport] | None = None,
plugins: list[Plugin] | None = None,
) -> None:
self.name = name
self._level = parse_level(level)
self.transports: list[Transport] = list(transports) if transports else []
self.plugins: list[Plugin] = list(plugins) if plugins else []

@property
def level(self) -> Level:
Expand All@@ -25,17 +29,45 @@ def level(self) -> Level:
def set_level(self, level: int | str | Level) -> None:
self._level = parse_level(level)

def use(self, plugin: Plugin) -> Logger:
"""Register a plugin. Returns `self` so calls can be chained."""
self.plugins.append(plugin)
return self

def close(self) -> None:
"""Close every attached transport. Call on shutdown to flush buffered writes."""
for transport in self.transports:
transport.close()

def _notify_error(self, plugin: Plugin, exc: Exception, record: LogRecord) -> None:
# a broken error handler must not crash logging either
with contextlib.suppress(Exception):
plugin.on_error(exc, record)

def _log(self, level: Level, message: str, meta: dict[str, Any]) -> LogRecord | None:
if level < self._level:
return None
record = create_record(level=level, logger=self.name, message=message, meta=meta)

for plugin in self.plugins:
try:
result = plugin.before_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)
continue
if result is None:
return None
record = result

for transport in self.transports:
transport.write(transport.format(record), record)

for plugin in self.plugins:
try:
plugin.after_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)

return record

def trace(self, message: str, **meta: Any) -> LogRecord | None:
Expand Down
22 changes: 22 additions & 0 deletions logquill/plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from logquill.records import LogRecord


class Plugin:
"""Base class for the plugin pipeline: `before_log`, `after_log`, `on_error`.

Override only the hooks you need — the rest default to no-ops. A plugin
hook that raises cannot crash logging: the pipeline catches it, routes it
to `on_error`, and moves on.
"""

def before_log(self, record: LogRecord) -> LogRecord | None:
"""Return a (possibly modified) record, or `None` to drop it."""
return record

def after_log(self, record: LogRecord) -> None:
"""Called after the record has been dispatched to every transport."""

def on_error(self, exc: Exception, record: LogRecord) -> None:
"""Called when one of this plugin's own hooks raises."""
28 changes: 28 additions & 0 deletions logquill/redact_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from collections.abc import Iterable

from logquill.plugin import Plugin
from logquill.records import LogRecord

DEFAULT_REDACTED_KEYS = frozenset({"password", "token", "secret", "api_key", "authorization"})


class RedactPlugin(Plugin):
"""Replaces sensitive `meta` values, matched by key (case-insensitive), with a placeholder."""

def __init__(
self,
keys: Iterable[str] = DEFAULT_REDACTED_KEYS,
replacement: str = "***",
) -> None:
self.keys = {key.lower() for key in keys}
self.replacement = replacement

def before_log(self, record: LogRecord) -> LogRecord | None:
meta = record["meta"]
record["meta"] = {
key: self.replacement if key.lower() in self.keys else value
for key, value in meta.items()
}
return record
20 changes: 20 additions & 0 deletions logquill/sampling_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

import random
from typing import Callable

from logquill.plugin import Plugin
from logquill.records import LogRecord


class SamplingPlugin(Plugin):
"""Keeps roughly `rate` of records (0.0-1.0), dropping the rest."""

def __init__(self, rate: float, rng: Callable[[], float] | None = None) -> None:
if not 0.0 <= rate <= 1.0:
raise ValueError(f"rate must be between 0 and 1, got {rate!r}")
self.rate = rate
self._rng = rng or random.random

def before_log(self, record: LogRecord) -> LogRecord | None:
return record if self._rng() < self.rate else None
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "logquill"
version = "0.1.2"
version = "0.1.3"
description = "A structured, leveled logging framework with pluggable transports and a plugin pipeline."
readme = "README.md"
license = "MIT"
Expand Down
20 changes: 20 additions & 0 deletions tests/test_context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from logquill.context_plugin import ContextPlugin
from logquill.logger import Logger


def test_injects_fixed_context_into_meta() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(service="api", env="prod")])

record = logger.info("hello", user_id=42)

assert record is not None
assert record["meta"] == {"service": "api", "env": "prod", "user_id": 42}


def test_call_site_meta_overrides_fixed_context() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(env="prod")])

record = logger.info("hello", env="staging")

assert record is not None
assert record["meta"]["env"] == "staging"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,5 +15,5 @@

## Scope

<!-- One phase/concern per PR where possible. Call out here if this
<!-- One concern per PR where possible. Call out here if this
intentionally spans more than one. -->
18 changes: 13 additions & 5 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,14 @@ All notable changes to this project are documented in this file.

## Unreleased

- Plugin pipeline: `Plugin` base (`before_log`/`after_log`/`on_error`,
all optional to override), `ContextPlugin` (merges fixed context into
`meta`), `RedactPlugin` (replaces sensitive `meta` values by key, case-
insensitive), and `SamplingPlugin` (probabilistically drops records).
`Logger` now accepts `plugins=[...]` and gained `.use(plugin)` to register
one and chain. A plugin hook that raises is caught, routed to that same
plugin's `on_error`, and the pipeline continues — a broken plugin can't
crash logging, verified by test.
- Added `.github/dependabot.yml`: weekly version updates for `pip`
dependencies and GitHub Actions.
- Added GitHub issue templates: `.github/ISSUE_TEMPLATE/bug_report.yml`,
Expand All@@ -12,23 +20,23 @@ All notable changes to this project are documented in this file.
- Added `.github/SECURITY.md`: supported-versions policy and instructions
to report vulnerabilities via GitHub's private vulnerability reporting
instead of public issues. Linked from the README.
- Phase 2 transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
- Transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
(colorized, ERROR/FATAL to stderr), `FileTransport` (size-based rotation), and
`HTTPTransport` (batched, newline-delimited JSON over stdlib `urllib`, with an
injectable `sender` for tests or alternate backends). `Logger` now accepts
`transports=[...]` and dispatches each record to them synchronously, and gained
`.close()` to close all attached transports. Dispatch is still synchronous —
the non-blocking queue/async path is Phase 4. Also added `CollectingTransport`,
an in-memory transport for tests.
a non-blocking queue/async path isn't implemented yet. Also added
`CollectingTransport`, an in-memory transport for tests.
- Added `CODE_OF_CONDUCT.md` (Contributor Covenant v2.1), `.github/CODEOWNERS`,
`.github/PULL_REQUEST_TEMPLATE.md`, and `CONTRIBUTING.md` documenting the
PR workflow (branch naming, scoping, review/CI requirements, squash-merge).
- Phase 1 core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
- Core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
logquill-js's numeric weights), `parse_level()`, the `LogRecord` shape,
`Logger` with `.trace()/.debug()/.info()/.warn()/.error()/.fatal()` and
`.set_level()`, and a `Formatter` protocol with a `JSONFormatter`
implementation. Log calls return the record dict (or `None` when filtered
by level) — no transports or dispatch yet, that's Phase 2.
by level) — no transports or dispatch yet.
- Repo scaffold: `pyproject.toml`, package skeleton, dev tooling (ruff, mypy --strict, pytest), pre-commit hooks, and CI workflow.
- Packaging metadata: expanded classifiers (OS, Topic) and keywords, added an `Issues` project URL, and fixed the `Homepage`/`Repository`/`Changelog` URLs to point at the actual `nikhilvdev/logquill-python` GitHub repo instead of a stale placeholder org.
- Added a pepy.tech download-count badge to the README for tracking installs.
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,8 @@ pytest

- **Branch from `main`**, name branches by intent: `feat/…`, `fix/…`,
`docs/…`, `chore/…` (e.g. `feat/rotating-file-transport`).
- **Keep PRs scoped to one phase or concern** where possible. A PR that
mixes an unrelated refactor with a feature is harder to review and harder
to revert.
- **Keep PRs scoped to one concern** where possible. A PR that mixes an
unrelated refactor with a feature is harder to review and harder to revert.
- **Every PR must satisfy this definition of done** before it's ready for
review:
1. Type hints throughout, `mypy --strict` clean on the public API
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,18 +14,20 @@ Sibling to [`logquill` on npm](https://www.npmjs.com/package/logquill)
across a Python + Node stack.

Status: pre-release, under active development. The core `Logger`, level
filtering, and transports are implemented; plugins and non-blocking async
dispatch are not yet — see `CHANGELOG.md` for what's landed so far.
filtering, transports, and the plugin pipeline are implemented;
non-blocking async dispatch is not yet — see `CHANGELOG.md` for what's
landed so far.

## Features

- **Structured by default** — every call carries a `meta` dict, not just a message string
- **Cross-language record shape** — identical JSON shape and level names/weights as [`logquill` on npm](https://www.npmjs.com/package/logquill)
- **Pluggable transports** — `ConsoleTransport` (colorized, stderr for errors), `FileTransport` (rotation), `HTTPTransport` (batched); write your own by subclassing `Transport`
- **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> str` for your own
- **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `SamplingPlugin` out of the box; a broken plugin can't crash logging
- **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP
- **Typed throughout** — `mypy --strict` clean on the public API
- *(planned)* plugin pipeline (redaction, sampling, rate limiting), non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`
- *(planned)* non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`

## Install

Expand DownExpand Up@@ -66,7 +68,7 @@ print(JSONFormatter().format(record))

Attach transports to a `Logger` to actually write records somewhere. Each
record is dispatched to every attached transport synchronously (non-blocking
dispatch lands in a later phase):
dispatch isn't implemented yet):

```python
from logquill import ConsoleTransport, FileTransport, HTTPTransport, Logger
Expand DownExpand Up@@ -99,6 +101,28 @@ logger.info("hello")
assert sink.records[0]["message"] == "hello"
```

## Plugins

Plugins hook into the pipeline around each log call: `before_log(record)` can
transform a record or return `None` to drop it, `after_log(record)` runs once
it's been dispatched to every transport, and `on_error(exc, record)` catches
anything a plugin's own hooks raise — a broken plugin can't take down logging.

```python
from logquill import ContextPlugin, Logger, RedactPlugin, SamplingPlugin

logger = Logger("app")
logger.use(ContextPlugin(service="api", env="prod")) # merged into every record's meta
logger.use(RedactPlugin(keys=["password", "token"])) # replaces matching meta values
logger.use(SamplingPlugin(0.1)) # keep ~10% of records that reach this point

logger.info("login attempt", user_id=42, password="hunter2")
# meta: {'service': 'api', 'env': 'prod', 'user_id': 42, 'password': '***'}
# (unless this call was one of the ~90% sampling dropped, in which case it's None)
```

Write your own by subclassing `Plugin`; override only the hooks you need.

## Development

```bash
Expand Down
10 changes: 9 additions & 1 deletion logquill/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
from logquill.console_transport import ConsoleTransport
from logquill.context_plugin import ContextPlugin
from logquill.file_transport import FileTransport
from logquill.formatter import Formatter, JSONFormatter
from logquill.http_transport import HTTPTransport
from logquill.levels import Level, parse_level
from logquill.logger import Logger
from logquill.plugin import Plugin
from logquill.records import LogRecord
from logquill.redact_plugin import RedactPlugin
from logquill.sampling_plugin import SamplingPlugin
from logquill.transport import CollectingTransport, Transport

__version__ = "0.1.2"
__version__ = "0.1.3"

__all__ = [
"CollectingTransport",
"ConsoleTransport",
"ContextPlugin",
"FileTransport",
"Formatter",
"HTTPTransport",
"JSONFormatter",
"Level",
"LogRecord",
"Logger",
"Plugin",
"RedactPlugin",
"SamplingPlugin",
"Transport",
"parse_level",
"__version__",
Expand Down
20 changes: 20 additions & 0 deletions logquill/context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from typing import Any

from logquill.plugin import Plugin
from logquill.records import LogRecord


class ContextPlugin(Plugin):
"""Injects fixed key/value pairs into every record's `meta`.

A value already present in a record's own `meta` wins over the fixed context.
"""

def __init__(self, **context: Any) -> None:
self.context = context

def before_log(self, record: LogRecord) -> LogRecord | None:
record["meta"] = {**self.context, **record["meta"]}
return record
2 changes: 1 addition & 1 deletion logquill/http_transport.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@ class HTTPTransport(Transport):

Uses `urllib` (stdlib) by default so the core package stays dependency-free.
Pass `sender` to swap in a fake for tests, or a different backend (e.g. an
aiohttp-based one, once the async dispatch path from Phase 4 exists).
aiohttp-based one, once a non-blocking async dispatch path exists).
"""

def __init__(
Expand Down
32 changes: 32 additions & 0 deletions logquill/logger.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import contextlib
from typing import Any

from logquill.levels import Level, parse_level
from logquill.plugin import Plugin
from logquill.records import LogRecord, create_record
from logquill.transport import Transport

Expand All@@ -13,10 +15,12 @@ def __init__(
name: str,
level: int | str | Level = Level.INFO,
transports: list[Transport] | None = None,
plugins: list[Plugin] | None = None,
) -> None:
self.name = name
self._level = parse_level(level)
self.transports: list[Transport] = list(transports) if transports else []
self.plugins: list[Plugin] = list(plugins) if plugins else []

@property
def level(self) -> Level:
Expand All@@ -25,17 +29,45 @@ def level(self) -> Level:
def set_level(self, level: int | str | Level) -> None:
self._level = parse_level(level)

def use(self, plugin: Plugin) -> Logger:
"""Register a plugin. Returns `self` so calls can be chained."""
self.plugins.append(plugin)
return self

def close(self) -> None:
"""Close every attached transport. Call on shutdown to flush buffered writes."""
for transport in self.transports:
transport.close()

def _notify_error(self, plugin: Plugin, exc: Exception, record: LogRecord) -> None:
# a broken error handler must not crash logging either
with contextlib.suppress(Exception):
plugin.on_error(exc, record)

def _log(self, level: Level, message: str, meta: dict[str, Any]) -> LogRecord | None:
if level < self._level:
return None
record = create_record(level=level, logger=self.name, message=message, meta=meta)

for plugin in self.plugins:
try:
result = plugin.before_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)
continue
if result is None:
return None
record = result

for transport in self.transports:
transport.write(transport.format(record), record)

for plugin in self.plugins:
try:
plugin.after_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)

return record

def trace(self, message: str, **meta: Any) -> LogRecord | None:
Expand Down
22 changes: 22 additions & 0 deletions logquill/plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from logquill.records import LogRecord


class Plugin:
"""Base class for the plugin pipeline: `before_log`, `after_log`, `on_error`.

Override only the hooks you need — the rest default to no-ops. A plugin
hook that raises cannot crash logging: the pipeline catches it, routes it
to `on_error`, and moves on.
"""

def before_log(self, record: LogRecord) -> LogRecord | None:
"""Return a (possibly modified) record, or `None` to drop it."""
return record

def after_log(self, record: LogRecord) -> None:
"""Called after the record has been dispatched to every transport."""

def on_error(self, exc: Exception, record: LogRecord) -> None:
"""Called when one of this plugin's own hooks raises."""
28 changes: 28 additions & 0 deletions logquill/redact_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from collections.abc import Iterable

from logquill.plugin import Plugin
from logquill.records import LogRecord

DEFAULT_REDACTED_KEYS = frozenset({"password", "token", "secret", "api_key", "authorization"})


class RedactPlugin(Plugin):
"""Replaces sensitive `meta` values, matched by key (case-insensitive), with a placeholder."""

def __init__(
self,
keys: Iterable[str] = DEFAULT_REDACTED_KEYS,
replacement: str = "***",
) -> None:
self.keys = {key.lower() for key in keys}
self.replacement = replacement

def before_log(self, record: LogRecord) -> LogRecord | None:
meta = record["meta"]
record["meta"] = {
key: self.replacement if key.lower() in self.keys else value
for key, value in meta.items()
}
return record
20 changes: 20 additions & 0 deletions logquill/sampling_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

import random
from typing import Callable

from logquill.plugin import Plugin
from logquill.records import LogRecord


class SamplingPlugin(Plugin):
"""Keeps roughly `rate` of records (0.0-1.0), dropping the rest."""

def __init__(self, rate: float, rng: Callable[[], float] | None = None) -> None:
if not 0.0 <= rate <= 1.0:
raise ValueError(f"rate must be between 0 and 1, got {rate!r}")
self.rate = rate
self._rng = rng or random.random

def before_log(self, record: LogRecord) -> LogRecord | None:
return record if self._rng() < self.rate else None
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "logquill"
version = "0.1.2"
version = "0.1.3"
description = "A structured, leveled logging framework with pluggable transports and a plugin pipeline."
readme = "README.md"
license = "MIT"
Expand Down
20 changes: 20 additions & 0 deletions tests/test_context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from logquill.context_plugin import ContextPlugin
from logquill.logger import Logger


def test_injects_fixed_context_into_meta() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(service="api", env="prod")])

record = logger.info("hello", user_id=42)

assert record is not None
assert record["meta"] == {"service": "api", "env": "prod", "user_id": 42}


def test_call_site_meta_overrides_fixed_context() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(env="prod")])

record = logger.info("hello", env="staging")

assert record is not None
assert record["meta"]["env"] == "staging"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,5 +15,5 @@

## Scope

<!-- One phase/concern per PR where possible. Call out here if this
<!-- One concern per PR where possible. Call out here if this
intentionally spans more than one. -->
18 changes: 13 additions & 5 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,14 @@ All notable changes to this project are documented in this file.

## Unreleased

- Plugin pipeline: `Plugin` base (`before_log`/`after_log`/`on_error`,
all optional to override), `ContextPlugin` (merges fixed context into
`meta`), `RedactPlugin` (replaces sensitive `meta` values by key, case-
insensitive), and `SamplingPlugin` (probabilistically drops records).
`Logger` now accepts `plugins=[...]` and gained `.use(plugin)` to register
one and chain. A plugin hook that raises is caught, routed to that same
plugin's `on_error`, and the pipeline continues — a broken plugin can't
crash logging, verified by test.
- Added `.github/dependabot.yml`: weekly version updates for `pip`
dependencies and GitHub Actions.
- Added GitHub issue templates: `.github/ISSUE_TEMPLATE/bug_report.yml`,
Expand All@@ -12,23 +20,23 @@ All notable changes to this project are documented in this file.
- Added `.github/SECURITY.md`: supported-versions policy and instructions
to report vulnerabilities via GitHub's private vulnerability reporting
instead of public issues. Linked from the README.
- Phase 2 transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
- Transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
(colorized, ERROR/FATAL to stderr), `FileTransport` (size-based rotation), and
`HTTPTransport` (batched, newline-delimited JSON over stdlib `urllib`, with an
injectable `sender` for tests or alternate backends). `Logger` now accepts
`transports=[...]` and dispatches each record to them synchronously, and gained
`.close()` to close all attached transports. Dispatch is still synchronous —
the non-blocking queue/async path is Phase 4. Also added `CollectingTransport`,
an in-memory transport for tests.
a non-blocking queue/async path isn't implemented yet. Also added
`CollectingTransport`, an in-memory transport for tests.
- Added `CODE_OF_CONDUCT.md` (Contributor Covenant v2.1), `.github/CODEOWNERS`,
`.github/PULL_REQUEST_TEMPLATE.md`, and `CONTRIBUTING.md` documenting the
PR workflow (branch naming, scoping, review/CI requirements, squash-merge).
- Phase 1 core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
- Core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
logquill-js's numeric weights), `parse_level()`, the `LogRecord` shape,
`Logger` with `.trace()/.debug()/.info()/.warn()/.error()/.fatal()` and
`.set_level()`, and a `Formatter` protocol with a `JSONFormatter`
implementation. Log calls return the record dict (or `None` when filtered
by level) — no transports or dispatch yet, that's Phase 2.
by level) — no transports or dispatch yet.
- Repo scaffold: `pyproject.toml`, package skeleton, dev tooling (ruff, mypy --strict, pytest), pre-commit hooks, and CI workflow.
- Packaging metadata: expanded classifiers (OS, Topic) and keywords, added an `Issues` project URL, and fixed the `Homepage`/`Repository`/`Changelog` URLs to point at the actual `nikhilvdev/logquill-python` GitHub repo instead of a stale placeholder org.
- Added a pepy.tech download-count badge to the README for tracking installs.
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,8 @@ pytest

- **Branch from `main`**, name branches by intent: `feat/…`, `fix/…`,
`docs/…`, `chore/…` (e.g. `feat/rotating-file-transport`).
- **Keep PRs scoped to one phase or concern** where possible. A PR that
mixes an unrelated refactor with a feature is harder to review and harder
to revert.
- **Keep PRs scoped to one concern** where possible. A PR that mixes an
unrelated refactor with a feature is harder to review and harder to revert.
- **Every PR must satisfy this definition of done** before it's ready for
review:
1. Type hints throughout, `mypy --strict` clean on the public API
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,18 +14,20 @@ Sibling to [`logquill` on npm](https://www.npmjs.com/package/logquill)
across a Python + Node stack.

Status: pre-release, under active development. The core `Logger`, level
filtering, and transports are implemented; plugins and non-blocking async
dispatch are not yet — see `CHANGELOG.md` for what's landed so far.
filtering, transports, and the plugin pipeline are implemented;
non-blocking async dispatch is not yet — see `CHANGELOG.md` for what's
landed so far.

## Features

- **Structured by default** — every call carries a `meta` dict, not just a message string
- **Cross-language record shape** — identical JSON shape and level names/weights as [`logquill` on npm](https://www.npmjs.com/package/logquill)
- **Pluggable transports** — `ConsoleTransport` (colorized, stderr for errors), `FileTransport` (rotation), `HTTPTransport` (batched); write your own by subclassing `Transport`
- **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> str` for your own
- **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `SamplingPlugin` out of the box; a broken plugin can't crash logging
- **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP
- **Typed throughout** — `mypy --strict` clean on the public API
- *(planned)* plugin pipeline (redaction, sampling, rate limiting), non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`
- *(planned)* non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`

## Install

Expand DownExpand Up@@ -66,7 +68,7 @@ print(JSONFormatter().format(record))

Attach transports to a `Logger` to actually write records somewhere. Each
record is dispatched to every attached transport synchronously (non-blocking
dispatch lands in a later phase):
dispatch isn't implemented yet):

```python
from logquill import ConsoleTransport, FileTransport, HTTPTransport, Logger
Expand DownExpand Up@@ -99,6 +101,28 @@ logger.info("hello")
assert sink.records[0]["message"] == "hello"
```

## Plugins

Plugins hook into the pipeline around each log call: `before_log(record)` can
transform a record or return `None` to drop it, `after_log(record)` runs once
it's been dispatched to every transport, and `on_error(exc, record)` catches
anything a plugin's own hooks raise — a broken plugin can't take down logging.

```python
from logquill import ContextPlugin, Logger, RedactPlugin, SamplingPlugin

logger = Logger("app")
logger.use(ContextPlugin(service="api", env="prod")) # merged into every record's meta
logger.use(RedactPlugin(keys=["password", "token"])) # replaces matching meta values
logger.use(SamplingPlugin(0.1)) # keep ~10% of records that reach this point

logger.info("login attempt", user_id=42, password="hunter2")
# meta: {'service': 'api', 'env': 'prod', 'user_id': 42, 'password': '***'}
# (unless this call was one of the ~90% sampling dropped, in which case it's None)
```

Write your own by subclassing `Plugin`; override only the hooks you need.

## Development

```bash
Expand Down
10 changes: 9 additions & 1 deletion logquill/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
from logquill.console_transport import ConsoleTransport
from logquill.context_plugin import ContextPlugin
from logquill.file_transport import FileTransport
from logquill.formatter import Formatter, JSONFormatter
from logquill.http_transport import HTTPTransport
from logquill.levels import Level, parse_level
from logquill.logger import Logger
from logquill.plugin import Plugin
from logquill.records import LogRecord
from logquill.redact_plugin import RedactPlugin
from logquill.sampling_plugin import SamplingPlugin
from logquill.transport import CollectingTransport, Transport

__version__ = "0.1.2"
__version__ = "0.1.3"

__all__ = [
"CollectingTransport",
"ConsoleTransport",
"ContextPlugin",
"FileTransport",
"Formatter",
"HTTPTransport",
"JSONFormatter",
"Level",
"LogRecord",
"Logger",
"Plugin",
"RedactPlugin",
"SamplingPlugin",
"Transport",
"parse_level",
"__version__",
Expand Down
20 changes: 20 additions & 0 deletions logquill/context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from typing import Any

from logquill.plugin import Plugin
from logquill.records import LogRecord


class ContextPlugin(Plugin):
"""Injects fixed key/value pairs into every record's `meta`.

A value already present in a record's own `meta` wins over the fixed context.
"""

def __init__(self, **context: Any) -> None:
self.context = context

def before_log(self, record: LogRecord) -> LogRecord | None:
record["meta"] = {**self.context, **record["meta"]}
return record
2 changes: 1 addition & 1 deletion logquill/http_transport.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@ class HTTPTransport(Transport):

Uses `urllib` (stdlib) by default so the core package stays dependency-free.
Pass `sender` to swap in a fake for tests, or a different backend (e.g. an
aiohttp-based one, once the async dispatch path from Phase 4 exists).
aiohttp-based one, once a non-blocking async dispatch path exists).
"""

def __init__(
Expand Down
32 changes: 32 additions & 0 deletions logquill/logger.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import contextlib
from typing import Any

from logquill.levels import Level, parse_level
from logquill.plugin import Plugin
from logquill.records import LogRecord, create_record
from logquill.transport import Transport

Expand All@@ -13,10 +15,12 @@ def __init__(
name: str,
level: int | str | Level = Level.INFO,
transports: list[Transport] | None = None,
plugins: list[Plugin] | None = None,
) -> None:
self.name = name
self._level = parse_level(level)
self.transports: list[Transport] = list(transports) if transports else []
self.plugins: list[Plugin] = list(plugins) if plugins else []

@property
def level(self) -> Level:
Expand All@@ -25,17 +29,45 @@ def level(self) -> Level:
def set_level(self, level: int | str | Level) -> None:
self._level = parse_level(level)

def use(self, plugin: Plugin) -> Logger:
"""Register a plugin. Returns `self` so calls can be chained."""
self.plugins.append(plugin)
return self

def close(self) -> None:
"""Close every attached transport. Call on shutdown to flush buffered writes."""
for transport in self.transports:
transport.close()

def _notify_error(self, plugin: Plugin, exc: Exception, record: LogRecord) -> None:
# a broken error handler must not crash logging either
with contextlib.suppress(Exception):
plugin.on_error(exc, record)

def _log(self, level: Level, message: str, meta: dict[str, Any]) -> LogRecord | None:
if level < self._level:
return None
record = create_record(level=level, logger=self.name, message=message, meta=meta)

for plugin in self.plugins:
try:
result = plugin.before_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)
continue
if result is None:
return None
record = result

for transport in self.transports:
transport.write(transport.format(record), record)

for plugin in self.plugins:
try:
plugin.after_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)

return record

def trace(self, message: str, **meta: Any) -> LogRecord | None:
Expand Down
22 changes: 22 additions & 0 deletions logquill/plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from logquill.records import LogRecord


class Plugin:
"""Base class for the plugin pipeline: `before_log`, `after_log`, `on_error`.

Override only the hooks you need — the rest default to no-ops. A plugin
hook that raises cannot crash logging: the pipeline catches it, routes it
to `on_error`, and moves on.
"""

def before_log(self, record: LogRecord) -> LogRecord | None:
"""Return a (possibly modified) record, or `None` to drop it."""
return record

def after_log(self, record: LogRecord) -> None:
"""Called after the record has been dispatched to every transport."""

def on_error(self, exc: Exception, record: LogRecord) -> None:
"""Called when one of this plugin's own hooks raises."""
28 changes: 28 additions & 0 deletions logquill/redact_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from collections.abc import Iterable

from logquill.plugin import Plugin
from logquill.records import LogRecord

DEFAULT_REDACTED_KEYS = frozenset({"password", "token", "secret", "api_key", "authorization"})


class RedactPlugin(Plugin):
"""Replaces sensitive `meta` values, matched by key (case-insensitive), with a placeholder."""

def __init__(
self,
keys: Iterable[str] = DEFAULT_REDACTED_KEYS,
replacement: str = "***",
) -> None:
self.keys = {key.lower() for key in keys}
self.replacement = replacement

def before_log(self, record: LogRecord) -> LogRecord | None:
meta = record["meta"]
record["meta"] = {
key: self.replacement if key.lower() in self.keys else value
for key, value in meta.items()
}
return record
20 changes: 20 additions & 0 deletions logquill/sampling_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

import random
from typing import Callable

from logquill.plugin import Plugin
from logquill.records import LogRecord


class SamplingPlugin(Plugin):
"""Keeps roughly `rate` of records (0.0-1.0), dropping the rest."""

def __init__(self, rate: float, rng: Callable[[], float] | None = None) -> None:
if not 0.0 <= rate <= 1.0:
raise ValueError(f"rate must be between 0 and 1, got {rate!r}")
self.rate = rate
self._rng = rng or random.random

def before_log(self, record: LogRecord) -> LogRecord | None:
return record if self._rng() < self.rate else None
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "logquill"
version = "0.1.2"
version = "0.1.3"
description = "A structured, leveled logging framework with pluggable transports and a plugin pipeline."
readme = "README.md"
license = "MIT"
Expand Down
20 changes: 20 additions & 0 deletions tests/test_context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from logquill.context_plugin import ContextPlugin
from logquill.logger import Logger


def test_injects_fixed_context_into_meta() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(service="api", env="prod")])

record = logger.info("hello", user_id=42)

assert record is not None
assert record["meta"] == {"service": "api", "env": "prod", "user_id": 42}


def test_call_site_meta_overrides_fixed_context() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(env="prod")])

record = logger.info("hello", env="staging")

assert record is not None
assert record["meta"]["env"] == "staging"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,5 +15,5 @@

## Scope

<!-- One phase/concern per PR where possible. Call out here if this
<!-- One concern per PR where possible. Call out here if this
intentionally spans more than one. -->
18 changes: 13 additions & 5 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,14 @@ All notable changes to this project are documented in this file.

## Unreleased

- Plugin pipeline: `Plugin` base (`before_log`/`after_log`/`on_error`,
all optional to override), `ContextPlugin` (merges fixed context into
`meta`), `RedactPlugin` (replaces sensitive `meta` values by key, case-
insensitive), and `SamplingPlugin` (probabilistically drops records).
`Logger` now accepts `plugins=[...]` and gained `.use(plugin)` to register
one and chain. A plugin hook that raises is caught, routed to that same
plugin's `on_error`, and the pipeline continues — a broken plugin can't
crash logging, verified by test.
- Added `.github/dependabot.yml`: weekly version updates for `pip`
dependencies and GitHub Actions.
- Added GitHub issue templates: `.github/ISSUE_TEMPLATE/bug_report.yml`,
Expand All@@ -12,23 +20,23 @@ All notable changes to this project are documented in this file.
- Added `.github/SECURITY.md`: supported-versions policy and instructions
to report vulnerabilities via GitHub's private vulnerability reporting
instead of public issues. Linked from the README.
- Phase 2 transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
- Transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
(colorized, ERROR/FATAL to stderr), `FileTransport` (size-based rotation), and
`HTTPTransport` (batched, newline-delimited JSON over stdlib `urllib`, with an
injectable `sender` for tests or alternate backends). `Logger` now accepts
`transports=[...]` and dispatches each record to them synchronously, and gained
`.close()` to close all attached transports. Dispatch is still synchronous —
the non-blocking queue/async path is Phase 4. Also added `CollectingTransport`,
an in-memory transport for tests.
a non-blocking queue/async path isn't implemented yet. Also added
`CollectingTransport`, an in-memory transport for tests.
- Added `CODE_OF_CONDUCT.md` (Contributor Covenant v2.1), `.github/CODEOWNERS`,
`.github/PULL_REQUEST_TEMPLATE.md`, and `CONTRIBUTING.md` documenting the
PR workflow (branch naming, scoping, review/CI requirements, squash-merge).
- Phase 1 core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
- Core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
logquill-js's numeric weights), `parse_level()`, the `LogRecord` shape,
`Logger` with `.trace()/.debug()/.info()/.warn()/.error()/.fatal()` and
`.set_level()`, and a `Formatter` protocol with a `JSONFormatter`
implementation. Log calls return the record dict (or `None` when filtered
by level) — no transports or dispatch yet, that's Phase 2.
by level) — no transports or dispatch yet.
- Repo scaffold: `pyproject.toml`, package skeleton, dev tooling (ruff, mypy --strict, pytest), pre-commit hooks, and CI workflow.
- Packaging metadata: expanded classifiers (OS, Topic) and keywords, added an `Issues` project URL, and fixed the `Homepage`/`Repository`/`Changelog` URLs to point at the actual `nikhilvdev/logquill-python` GitHub repo instead of a stale placeholder org.
- Added a pepy.tech download-count badge to the README for tracking installs.
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,8 @@ pytest

- **Branch from `main`**, name branches by intent: `feat/…`, `fix/…`,
`docs/…`, `chore/…` (e.g. `feat/rotating-file-transport`).
- **Keep PRs scoped to one phase or concern** where possible. A PR that
mixes an unrelated refactor with a feature is harder to review and harder
to revert.
- **Keep PRs scoped to one concern** where possible. A PR that mixes an
unrelated refactor with a feature is harder to review and harder to revert.
- **Every PR must satisfy this definition of done** before it's ready for
review:
1. Type hints throughout, `mypy --strict` clean on the public API
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,18 +14,20 @@ Sibling to [`logquill` on npm](https://www.npmjs.com/package/logquill)
across a Python + Node stack.

Status: pre-release, under active development. The core `Logger`, level
filtering, and transports are implemented; plugins and non-blocking async
dispatch are not yet — see `CHANGELOG.md` for what's landed so far.
filtering, transports, and the plugin pipeline are implemented;
non-blocking async dispatch is not yet — see `CHANGELOG.md` for what's
landed so far.

## Features

- **Structured by default** — every call carries a `meta` dict, not just a message string
- **Cross-language record shape** — identical JSON shape and level names/weights as [`logquill` on npm](https://www.npmjs.com/package/logquill)
- **Pluggable transports** — `ConsoleTransport` (colorized, stderr for errors), `FileTransport` (rotation), `HTTPTransport` (batched); write your own by subclassing `Transport`
- **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> str` for your own
- **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `SamplingPlugin` out of the box; a broken plugin can't crash logging
- **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP
- **Typed throughout** — `mypy --strict` clean on the public API
- *(planned)* plugin pipeline (redaction, sampling, rate limiting), non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`
- *(planned)* non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`

## Install

Expand DownExpand Up@@ -66,7 +68,7 @@ print(JSONFormatter().format(record))

Attach transports to a `Logger` to actually write records somewhere. Each
record is dispatched to every attached transport synchronously (non-blocking
dispatch lands in a later phase):
dispatch isn't implemented yet):

```python
from logquill import ConsoleTransport, FileTransport, HTTPTransport, Logger
Expand DownExpand Up@@ -99,6 +101,28 @@ logger.info("hello")
assert sink.records[0]["message"] == "hello"
```

## Plugins

Plugins hook into the pipeline around each log call: `before_log(record)` can
transform a record or return `None` to drop it, `after_log(record)` runs once
it's been dispatched to every transport, and `on_error(exc, record)` catches
anything a plugin's own hooks raise — a broken plugin can't take down logging.

```python
from logquill import ContextPlugin, Logger, RedactPlugin, SamplingPlugin

logger = Logger("app")
logger.use(ContextPlugin(service="api", env="prod")) # merged into every record's meta
logger.use(RedactPlugin(keys=["password", "token"])) # replaces matching meta values
logger.use(SamplingPlugin(0.1)) # keep ~10% of records that reach this point

logger.info("login attempt", user_id=42, password="hunter2")
# meta: {'service': 'api', 'env': 'prod', 'user_id': 42, 'password': '***'}
# (unless this call was one of the ~90% sampling dropped, in which case it's None)
```

Write your own by subclassing `Plugin`; override only the hooks you need.

## Development

```bash
Expand Down
10 changes: 9 additions & 1 deletion logquill/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
from logquill.console_transport import ConsoleTransport
from logquill.context_plugin import ContextPlugin
from logquill.file_transport import FileTransport
from logquill.formatter import Formatter, JSONFormatter
from logquill.http_transport import HTTPTransport
from logquill.levels import Level, parse_level
from logquill.logger import Logger
from logquill.plugin import Plugin
from logquill.records import LogRecord
from logquill.redact_plugin import RedactPlugin
from logquill.sampling_plugin import SamplingPlugin
from logquill.transport import CollectingTransport, Transport

__version__ = "0.1.2"
__version__ = "0.1.3"

__all__ = [
"CollectingTransport",
"ConsoleTransport",
"ContextPlugin",
"FileTransport",
"Formatter",
"HTTPTransport",
"JSONFormatter",
"Level",
"LogRecord",
"Logger",
"Plugin",
"RedactPlugin",
"SamplingPlugin",
"Transport",
"parse_level",
"__version__",
Expand Down
20 changes: 20 additions & 0 deletions logquill/context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from typing import Any

from logquill.plugin import Plugin
from logquill.records import LogRecord


class ContextPlugin(Plugin):
"""Injects fixed key/value pairs into every record's `meta`.

A value already present in a record's own `meta` wins over the fixed context.
"""

def __init__(self, **context: Any) -> None:
self.context = context

def before_log(self, record: LogRecord) -> LogRecord | None:
record["meta"] = {**self.context, **record["meta"]}
return record
2 changes: 1 addition & 1 deletion logquill/http_transport.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@ class HTTPTransport(Transport):

Uses `urllib` (stdlib) by default so the core package stays dependency-free.
Pass `sender` to swap in a fake for tests, or a different backend (e.g. an
aiohttp-based one, once the async dispatch path from Phase 4 exists).
aiohttp-based one, once a non-blocking async dispatch path exists).
"""

def __init__(
Expand Down
32 changes: 32 additions & 0 deletions logquill/logger.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import contextlib
from typing import Any

from logquill.levels import Level, parse_level
from logquill.plugin import Plugin
from logquill.records import LogRecord, create_record
from logquill.transport import Transport

Expand All@@ -13,10 +15,12 @@ def __init__(
name: str,
level: int | str | Level = Level.INFO,
transports: list[Transport] | None = None,
plugins: list[Plugin] | None = None,
) -> None:
self.name = name
self._level = parse_level(level)
self.transports: list[Transport] = list(transports) if transports else []
self.plugins: list[Plugin] = list(plugins) if plugins else []

@property
def level(self) -> Level:
Expand All@@ -25,17 +29,45 @@ def level(self) -> Level:
def set_level(self, level: int | str | Level) -> None:
self._level = parse_level(level)

def use(self, plugin: Plugin) -> Logger:
"""Register a plugin. Returns `self` so calls can be chained."""
self.plugins.append(plugin)
return self

def close(self) -> None:
"""Close every attached transport. Call on shutdown to flush buffered writes."""
for transport in self.transports:
transport.close()

def _notify_error(self, plugin: Plugin, exc: Exception, record: LogRecord) -> None:
# a broken error handler must not crash logging either
with contextlib.suppress(Exception):
plugin.on_error(exc, record)

def _log(self, level: Level, message: str, meta: dict[str, Any]) -> LogRecord | None:
if level < self._level:
return None
record = create_record(level=level, logger=self.name, message=message, meta=meta)

for plugin in self.plugins:
try:
result = plugin.before_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)
continue
if result is None:
return None
record = result

for transport in self.transports:
transport.write(transport.format(record), record)

for plugin in self.plugins:
try:
plugin.after_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)

return record

def trace(self, message: str, **meta: Any) -> LogRecord | None:
Expand Down
22 changes: 22 additions & 0 deletions logquill/plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from logquill.records import LogRecord


class Plugin:
"""Base class for the plugin pipeline: `before_log`, `after_log`, `on_error`.

Override only the hooks you need — the rest default to no-ops. A plugin
hook that raises cannot crash logging: the pipeline catches it, routes it
to `on_error`, and moves on.
"""

def before_log(self, record: LogRecord) -> LogRecord | None:
"""Return a (possibly modified) record, or `None` to drop it."""
return record

def after_log(self, record: LogRecord) -> None:
"""Called after the record has been dispatched to every transport."""

def on_error(self, exc: Exception, record: LogRecord) -> None:
"""Called when one of this plugin's own hooks raises."""
28 changes: 28 additions & 0 deletions logquill/redact_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from collections.abc import Iterable

from logquill.plugin import Plugin
from logquill.records import LogRecord

DEFAULT_REDACTED_KEYS = frozenset({"password", "token", "secret", "api_key", "authorization"})


class RedactPlugin(Plugin):
"""Replaces sensitive `meta` values, matched by key (case-insensitive), with a placeholder."""

def __init__(
self,
keys: Iterable[str] = DEFAULT_REDACTED_KEYS,
replacement: str = "***",
) -> None:
self.keys = {key.lower() for key in keys}
self.replacement = replacement

def before_log(self, record: LogRecord) -> LogRecord | None:
meta = record["meta"]
record["meta"] = {
key: self.replacement if key.lower() in self.keys else value
for key, value in meta.items()
}
return record
20 changes: 20 additions & 0 deletions logquill/sampling_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

import random
from typing import Callable

from logquill.plugin import Plugin
from logquill.records import LogRecord


class SamplingPlugin(Plugin):
"""Keeps roughly `rate` of records (0.0-1.0), dropping the rest."""

def __init__(self, rate: float, rng: Callable[[], float] | None = None) -> None:
if not 0.0 <= rate <= 1.0:
raise ValueError(f"rate must be between 0 and 1, got {rate!r}")
self.rate = rate
self._rng = rng or random.random

def before_log(self, record: LogRecord) -> LogRecord | None:
return record if self._rng() < self.rate else None
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "logquill"
version = "0.1.2"
version = "0.1.3"
description = "A structured, leveled logging framework with pluggable transports and a plugin pipeline."
readme = "README.md"
license = "MIT"
Expand Down
20 changes: 20 additions & 0 deletions tests/test_context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from logquill.context_plugin import ContextPlugin
from logquill.logger import Logger


def test_injects_fixed_context_into_meta() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(service="api", env="prod")])

record = logger.info("hello", user_id=42)

assert record is not None
assert record["meta"] == {"service": "api", "env": "prod", "user_id": 42}


def test_call_site_meta_overrides_fixed_context() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(env="prod")])

record = logger.info("hello", env="staging")

assert record is not None
assert record["meta"]["env"] == "staging"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,5 +15,5 @@

## Scope

<!-- One phase/concern per PR where possible. Call out here if this
<!-- One concern per PR where possible. Call out here if this
intentionally spans more than one. -->
18 changes: 13 additions & 5 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,14 @@ All notable changes to this project are documented in this file.

## Unreleased

- Plugin pipeline: `Plugin` base (`before_log`/`after_log`/`on_error`,
all optional to override), `ContextPlugin` (merges fixed context into
`meta`), `RedactPlugin` (replaces sensitive `meta` values by key, case-
insensitive), and `SamplingPlugin` (probabilistically drops records).
`Logger` now accepts `plugins=[...]` and gained `.use(plugin)` to register
one and chain. A plugin hook that raises is caught, routed to that same
plugin's `on_error`, and the pipeline continues — a broken plugin can't
crash logging, verified by test.
- Added `.github/dependabot.yml`: weekly version updates for `pip`
dependencies and GitHub Actions.
- Added GitHub issue templates: `.github/ISSUE_TEMPLATE/bug_report.yml`,
Expand All@@ -12,23 +20,23 @@ All notable changes to this project are documented in this file.
- Added `.github/SECURITY.md`: supported-versions policy and instructions
to report vulnerabilities via GitHub's private vulnerability reporting
instead of public issues. Linked from the README.
- Phase 2 transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
- Transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
(colorized, ERROR/FATAL to stderr), `FileTransport` (size-based rotation), and
`HTTPTransport` (batched, newline-delimited JSON over stdlib `urllib`, with an
injectable `sender` for tests or alternate backends). `Logger` now accepts
`transports=[...]` and dispatches each record to them synchronously, and gained
`.close()` to close all attached transports. Dispatch is still synchronous —
the non-blocking queue/async path is Phase 4. Also added `CollectingTransport`,
an in-memory transport for tests.
a non-blocking queue/async path isn't implemented yet. Also added
`CollectingTransport`, an in-memory transport for tests.
- Added `CODE_OF_CONDUCT.md` (Contributor Covenant v2.1), `.github/CODEOWNERS`,
`.github/PULL_REQUEST_TEMPLATE.md`, and `CONTRIBUTING.md` documenting the
PR workflow (branch naming, scoping, review/CI requirements, squash-merge).
- Phase 1 core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
- Core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
logquill-js's numeric weights), `parse_level()`, the `LogRecord` shape,
`Logger` with `.trace()/.debug()/.info()/.warn()/.error()/.fatal()` and
`.set_level()`, and a `Formatter` protocol with a `JSONFormatter`
implementation. Log calls return the record dict (or `None` when filtered
by level) — no transports or dispatch yet, that's Phase 2.
by level) — no transports or dispatch yet.
- Repo scaffold: `pyproject.toml`, package skeleton, dev tooling (ruff, mypy --strict, pytest), pre-commit hooks, and CI workflow.
- Packaging metadata: expanded classifiers (OS, Topic) and keywords, added an `Issues` project URL, and fixed the `Homepage`/`Repository`/`Changelog` URLs to point at the actual `nikhilvdev/logquill-python` GitHub repo instead of a stale placeholder org.
- Added a pepy.tech download-count badge to the README for tracking installs.
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,8 @@ pytest

- **Branch from `main`**, name branches by intent: `feat/…`, `fix/…`,
`docs/…`, `chore/…` (e.g. `feat/rotating-file-transport`).
- **Keep PRs scoped to one phase or concern** where possible. A PR that
mixes an unrelated refactor with a feature is harder to review and harder
to revert.
- **Keep PRs scoped to one concern** where possible. A PR that mixes an
unrelated refactor with a feature is harder to review and harder to revert.
- **Every PR must satisfy this definition of done** before it's ready for
review:
1. Type hints throughout, `mypy --strict` clean on the public API
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,18 +14,20 @@ Sibling to [`logquill` on npm](https://www.npmjs.com/package/logquill)
across a Python + Node stack.

Status: pre-release, under active development. The core `Logger`, level
filtering, and transports are implemented; plugins and non-blocking async
dispatch are not yet — see `CHANGELOG.md` for what's landed so far.
filtering, transports, and the plugin pipeline are implemented;
non-blocking async dispatch is not yet — see `CHANGELOG.md` for what's
landed so far.

## Features

- **Structured by default** — every call carries a `meta` dict, not just a message string
- **Cross-language record shape** — identical JSON shape and level names/weights as [`logquill` on npm](https://www.npmjs.com/package/logquill)
- **Pluggable transports** — `ConsoleTransport` (colorized, stderr for errors), `FileTransport` (rotation), `HTTPTransport` (batched); write your own by subclassing `Transport`
- **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> str` for your own
- **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `SamplingPlugin` out of the box; a broken plugin can't crash logging
- **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP
- **Typed throughout** — `mypy --strict` clean on the public API
- *(planned)* plugin pipeline (redaction, sampling, rate limiting), non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`
- *(planned)* non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`

## Install

Expand DownExpand Up@@ -66,7 +68,7 @@ print(JSONFormatter().format(record))

Attach transports to a `Logger` to actually write records somewhere. Each
record is dispatched to every attached transport synchronously (non-blocking
dispatch lands in a later phase):
dispatch isn't implemented yet):

```python
from logquill import ConsoleTransport, FileTransport, HTTPTransport, Logger
Expand DownExpand Up@@ -99,6 +101,28 @@ logger.info("hello")
assert sink.records[0]["message"] == "hello"
```

## Plugins

Plugins hook into the pipeline around each log call: `before_log(record)` can
transform a record or return `None` to drop it, `after_log(record)` runs once
it's been dispatched to every transport, and `on_error(exc, record)` catches
anything a plugin's own hooks raise — a broken plugin can't take down logging.

```python
from logquill import ContextPlugin, Logger, RedactPlugin, SamplingPlugin

logger = Logger("app")
logger.use(ContextPlugin(service="api", env="prod")) # merged into every record's meta
logger.use(RedactPlugin(keys=["password", "token"])) # replaces matching meta values
logger.use(SamplingPlugin(0.1)) # keep ~10% of records that reach this point

logger.info("login attempt", user_id=42, password="hunter2")
# meta: {'service': 'api', 'env': 'prod', 'user_id': 42, 'password': '***'}
# (unless this call was one of the ~90% sampling dropped, in which case it's None)
```

Write your own by subclassing `Plugin`; override only the hooks you need.

## Development

```bash
Expand Down
10 changes: 9 additions & 1 deletion logquill/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
from logquill.console_transport import ConsoleTransport
from logquill.context_plugin import ContextPlugin
from logquill.file_transport import FileTransport
from logquill.formatter import Formatter, JSONFormatter
from logquill.http_transport import HTTPTransport
from logquill.levels import Level, parse_level
from logquill.logger import Logger
from logquill.plugin import Plugin
from logquill.records import LogRecord
from logquill.redact_plugin import RedactPlugin
from logquill.sampling_plugin import SamplingPlugin
from logquill.transport import CollectingTransport, Transport

__version__ = "0.1.2"
__version__ = "0.1.3"

__all__ = [
"CollectingTransport",
"ConsoleTransport",
"ContextPlugin",
"FileTransport",
"Formatter",
"HTTPTransport",
"JSONFormatter",
"Level",
"LogRecord",
"Logger",
"Plugin",
"RedactPlugin",
"SamplingPlugin",
"Transport",
"parse_level",
"__version__",
Expand Down
20 changes: 20 additions & 0 deletions logquill/context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from typing import Any

from logquill.plugin import Plugin
from logquill.records import LogRecord


class ContextPlugin(Plugin):
"""Injects fixed key/value pairs into every record's `meta`.

A value already present in a record's own `meta` wins over the fixed context.
"""

def __init__(self, **context: Any) -> None:
self.context = context

def before_log(self, record: LogRecord) -> LogRecord | None:
record["meta"] = {**self.context, **record["meta"]}
return record
2 changes: 1 addition & 1 deletion logquill/http_transport.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@ class HTTPTransport(Transport):

Uses `urllib` (stdlib) by default so the core package stays dependency-free.
Pass `sender` to swap in a fake for tests, or a different backend (e.g. an
aiohttp-based one, once the async dispatch path from Phase 4 exists).
aiohttp-based one, once a non-blocking async dispatch path exists).
"""

def __init__(
Expand Down
32 changes: 32 additions & 0 deletions logquill/logger.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import contextlib
from typing import Any

from logquill.levels import Level, parse_level
from logquill.plugin import Plugin
from logquill.records import LogRecord, create_record
from logquill.transport import Transport

Expand All@@ -13,10 +15,12 @@ def __init__(
name: str,
level: int | str | Level = Level.INFO,
transports: list[Transport] | None = None,
plugins: list[Plugin] | None = None,
) -> None:
self.name = name
self._level = parse_level(level)
self.transports: list[Transport] = list(transports) if transports else []
self.plugins: list[Plugin] = list(plugins) if plugins else []

@property
def level(self) -> Level:
Expand All@@ -25,17 +29,45 @@ def level(self) -> Level:
def set_level(self, level: int | str | Level) -> None:
self._level = parse_level(level)

def use(self, plugin: Plugin) -> Logger:
"""Register a plugin. Returns `self` so calls can be chained."""
self.plugins.append(plugin)
return self

def close(self) -> None:
"""Close every attached transport. Call on shutdown to flush buffered writes."""
for transport in self.transports:
transport.close()

def _notify_error(self, plugin: Plugin, exc: Exception, record: LogRecord) -> None:
# a broken error handler must not crash logging either
with contextlib.suppress(Exception):
plugin.on_error(exc, record)

def _log(self, level: Level, message: str, meta: dict[str, Any]) -> LogRecord | None:
if level < self._level:
return None
record = create_record(level=level, logger=self.name, message=message, meta=meta)

for plugin in self.plugins:
try:
result = plugin.before_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)
continue
if result is None:
return None
record = result

for transport in self.transports:
transport.write(transport.format(record), record)

for plugin in self.plugins:
try:
plugin.after_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)

return record

def trace(self, message: str, **meta: Any) -> LogRecord | None:
Expand Down
22 changes: 22 additions & 0 deletions logquill/plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from logquill.records import LogRecord


class Plugin:
"""Base class for the plugin pipeline: `before_log`, `after_log`, `on_error`.

Override only the hooks you need — the rest default to no-ops. A plugin
hook that raises cannot crash logging: the pipeline catches it, routes it
to `on_error`, and moves on.
"""

def before_log(self, record: LogRecord) -> LogRecord | None:
"""Return a (possibly modified) record, or `None` to drop it."""
return record

def after_log(self, record: LogRecord) -> None:
"""Called after the record has been dispatched to every transport."""

def on_error(self, exc: Exception, record: LogRecord) -> None:
"""Called when one of this plugin's own hooks raises."""
28 changes: 28 additions & 0 deletions logquill/redact_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from collections.abc import Iterable

from logquill.plugin import Plugin
from logquill.records import LogRecord

DEFAULT_REDACTED_KEYS = frozenset({"password", "token", "secret", "api_key", "authorization"})


class RedactPlugin(Plugin):
"""Replaces sensitive `meta` values, matched by key (case-insensitive), with a placeholder."""

def __init__(
self,
keys: Iterable[str] = DEFAULT_REDACTED_KEYS,
replacement: str = "***",
) -> None:
self.keys = {key.lower() for key in keys}
self.replacement = replacement

def before_log(self, record: LogRecord) -> LogRecord | None:
meta = record["meta"]
record["meta"] = {
key: self.replacement if key.lower() in self.keys else value
for key, value in meta.items()
}
return record
20 changes: 20 additions & 0 deletions logquill/sampling_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

import random
from typing import Callable

from logquill.plugin import Plugin
from logquill.records import LogRecord


class SamplingPlugin(Plugin):
"""Keeps roughly `rate` of records (0.0-1.0), dropping the rest."""

def __init__(self, rate: float, rng: Callable[[], float] | None = None) -> None:
if not 0.0 <= rate <= 1.0:
raise ValueError(f"rate must be between 0 and 1, got {rate!r}")
self.rate = rate
self._rng = rng or random.random

def before_log(self, record: LogRecord) -> LogRecord | None:
return record if self._rng() < self.rate else None
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "logquill"
version = "0.1.2"
version = "0.1.3"
description = "A structured, leveled logging framework with pluggable transports and a plugin pipeline."
readme = "README.md"
license = "MIT"
Expand Down
20 changes: 20 additions & 0 deletions tests/test_context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from logquill.context_plugin import ContextPlugin
from logquill.logger import Logger


def test_injects_fixed_context_into_meta() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(service="api", env="prod")])

record = logger.info("hello", user_id=42)

assert record is not None
assert record["meta"] == {"service": "api", "env": "prod", "user_id": 42}


def test_call_site_meta_overrides_fixed_context() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(env="prod")])

record = logger.info("hello", env="staging")

assert record is not None
assert record["meta"]["env"] == "staging"
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/PULL_REQUEST_TEMPLATE.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -15,5 +15,5 @@

## Scope

<!-- One phase/concern per PR where possible. Call out here if this
<!-- One concern per PR where possible. Call out here if this
intentionally spans more than one. -->
18 changes: 13 additions & 5 deletions CHANGELOG.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,6 +4,14 @@ All notable changes to this project are documented in this file.

## Unreleased

- Plugin pipeline: `Plugin` base (`before_log`/`after_log`/`on_error`,
all optional to override), `ContextPlugin` (merges fixed context into
`meta`), `RedactPlugin` (replaces sensitive `meta` values by key, case-
insensitive), and `SamplingPlugin` (probabilistically drops records).
`Logger` now accepts `plugins=[...]` and gained `.use(plugin)` to register
one and chain. A plugin hook that raises is caught, routed to that same
plugin's `on_error`, and the pipeline continues — a broken plugin can't
crash logging, verified by test.
- Added `.github/dependabot.yml`: weekly version updates for `pip`
dependencies and GitHub Actions.
- Added GitHub issue templates: `.github/ISSUE_TEMPLATE/bug_report.yml`,
Expand All@@ -12,23 +20,23 @@ All notable changes to this project are documented in this file.
- Added `.github/SECURITY.md`: supported-versions policy and instructions
to report vulnerabilities via GitHub's private vulnerability reporting
instead of public issues. Linked from the README.
- Phase 2 transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
- Transports: `Transport` base (`format`/`write`/`close`), `ConsoleTransport`
(colorized, ERROR/FATAL to stderr), `FileTransport` (size-based rotation), and
`HTTPTransport` (batched, newline-delimited JSON over stdlib `urllib`, with an
injectable `sender` for tests or alternate backends). `Logger` now accepts
`transports=[...]` and dispatches each record to them synchronously, and gained
`.close()` to close all attached transports. Dispatch is still synchronous —
the non-blocking queue/async path is Phase 4. Also added `CollectingTransport`,
an in-memory transport for tests.
a non-blocking queue/async path isn't implemented yet. Also added
`CollectingTransport`, an in-memory transport for tests.
- Added `CODE_OF_CONDUCT.md` (Contributor Covenant v2.1), `.github/CODEOWNERS`,
`.github/PULL_REQUEST_TEMPLATE.md`, and `CONTRIBUTING.md` documenting the
PR workflow (branch naming, scoping, review/CI requirements, squash-merge).
- Phase 1 core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
- Core API: `Level` (TRACE/DEBUG/INFO/WARN/ERROR/FATAL, matching
logquill-js's numeric weights), `parse_level()`, the `LogRecord` shape,
`Logger` with `.trace()/.debug()/.info()/.warn()/.error()/.fatal()` and
`.set_level()`, and a `Formatter` protocol with a `JSONFormatter`
implementation. Log calls return the record dict (or `None` when filtered
by level) — no transports or dispatch yet, that's Phase 2.
by level) — no transports or dispatch yet.
- Repo scaffold: `pyproject.toml`, package skeleton, dev tooling (ruff, mypy --strict, pytest), pre-commit hooks, and CI workflow.
- Packaging metadata: expanded classifiers (OS, Topic) and keywords, added an `Issues` project URL, and fixed the `Homepage`/`Repository`/`Changelog` URLs to point at the actual `nikhilvdev/logquill-python` GitHub repo instead of a stale placeholder org.
- Added a pepy.tech download-count badge to the README for tracking installs.
5 changes: 2 additions & 3 deletions CONTRIBUTING.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,9 +21,8 @@ pytest

- **Branch from `main`**, name branches by intent: `feat/…`, `fix/…`,
`docs/…`, `chore/…` (e.g. `feat/rotating-file-transport`).
- **Keep PRs scoped to one phase or concern** where possible. A PR that
mixes an unrelated refactor with a feature is harder to review and harder
to revert.
- **Keep PRs scoped to one concern** where possible. A PR that mixes an
unrelated refactor with a feature is harder to review and harder to revert.
- **Every PR must satisfy this definition of done** before it's ready for
review:
1. Type hints throughout, `mypy --strict` clean on the public API
Expand Down
32 changes: 28 additions & 4 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -14,18 +14,20 @@ Sibling to [`logquill` on npm](https://www.npmjs.com/package/logquill)
across a Python + Node stack.

Status: pre-release, under active development. The core `Logger`, level
filtering, and transports are implemented; plugins and non-blocking async
dispatch are not yet — see `CHANGELOG.md` for what's landed so far.
filtering, transports, and the plugin pipeline are implemented;
non-blocking async dispatch is not yet — see `CHANGELOG.md` for what's
landed so far.

## Features

- **Structured by default** — every call carries a `meta` dict, not just a message string
- **Cross-language record shape** — identical JSON shape and level names/weights as [`logquill` on npm](https://www.npmjs.com/package/logquill)
- **Pluggable transports** — `ConsoleTransport` (colorized, stderr for errors), `FileTransport` (rotation), `HTTPTransport` (batched); write your own by subclassing `Transport`
- **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> str` for your own
- **Plugin pipeline** — `ContextPlugin`, `RedactPlugin`, `SamplingPlugin` out of the box; a broken plugin can't crash logging
- **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP
- **Typed throughout** — `mypy --strict` clean on the public API
- *(planned)* plugin pipeline (redaction, sampling, rate limiting), non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`
- *(planned)* non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`

## Install

Expand DownExpand Up@@ -66,7 +68,7 @@ print(JSONFormatter().format(record))

Attach transports to a `Logger` to actually write records somewhere. Each
record is dispatched to every attached transport synchronously (non-blocking
dispatch lands in a later phase):
dispatch isn't implemented yet):

```python
from logquill import ConsoleTransport, FileTransport, HTTPTransport, Logger
Expand DownExpand Up@@ -99,6 +101,28 @@ logger.info("hello")
assert sink.records[0]["message"] == "hello"
```

## Plugins

Plugins hook into the pipeline around each log call: `before_log(record)` can
transform a record or return `None` to drop it, `after_log(record)` runs once
it's been dispatched to every transport, and `on_error(exc, record)` catches
anything a plugin's own hooks raise — a broken plugin can't take down logging.

```python
from logquill import ContextPlugin, Logger, RedactPlugin, SamplingPlugin

logger = Logger("app")
logger.use(ContextPlugin(service="api", env="prod")) # merged into every record's meta
logger.use(RedactPlugin(keys=["password", "token"])) # replaces matching meta values
logger.use(SamplingPlugin(0.1)) # keep ~10% of records that reach this point

logger.info("login attempt", user_id=42, password="hunter2")
# meta: {'service': 'api', 'env': 'prod', 'user_id': 42, 'password': '***'}
# (unless this call was one of the ~90% sampling dropped, in which case it's None)
```

Write your own by subclassing `Plugin`; override only the hooks you need.

## Development

```bash
Expand Down
10 changes: 9 additions & 1 deletion logquill/__init__.py
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,32 @@
from logquill.console_transport import ConsoleTransport
from logquill.context_plugin import ContextPlugin
from logquill.file_transport import FileTransport
from logquill.formatter import Formatter, JSONFormatter
from logquill.http_transport import HTTPTransport
from logquill.levels import Level, parse_level
from logquill.logger import Logger
from logquill.plugin import Plugin
from logquill.records import LogRecord
from logquill.redact_plugin import RedactPlugin
from logquill.sampling_plugin import SamplingPlugin
from logquill.transport import CollectingTransport, Transport

__version__ = "0.1.2"
__version__ = "0.1.3"

__all__ = [
"CollectingTransport",
"ConsoleTransport",
"ContextPlugin",
"FileTransport",
"Formatter",
"HTTPTransport",
"JSONFormatter",
"Level",
"LogRecord",
"Logger",
"Plugin",
"RedactPlugin",
"SamplingPlugin",
"Transport",
"parse_level",
"__version__",
Expand Down
20 changes: 20 additions & 0 deletions logquill/context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

from typing import Any

from logquill.plugin import Plugin
from logquill.records import LogRecord


class ContextPlugin(Plugin):
"""Injects fixed key/value pairs into every record's `meta`.

A value already present in a record's own `meta` wins over the fixed context.
"""

def __init__(self, **context: Any) -> None:
self.context = context

def before_log(self, record: LogRecord) -> LogRecord | None:
record["meta"] = {**self.context, **record["meta"]}
return record
2 changes: 1 addition & 1 deletion logquill/http_transport.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -27,7 +27,7 @@ class HTTPTransport(Transport):

Uses `urllib` (stdlib) by default so the core package stays dependency-free.
Pass `sender` to swap in a fake for tests, or a different backend (e.g. an
aiohttp-based one, once the async dispatch path from Phase 4 exists).
aiohttp-based one, once a non-blocking async dispatch path exists).
"""

def __init__(
Expand Down
32 changes: 32 additions & 0 deletions logquill/logger.py
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
from __future__ import annotations

import contextlib
from typing import Any

from logquill.levels import Level, parse_level
from logquill.plugin import Plugin
from logquill.records import LogRecord, create_record
from logquill.transport import Transport

Expand All@@ -13,10 +15,12 @@ def __init__(
name: str,
level: int | str | Level = Level.INFO,
transports: list[Transport] | None = None,
plugins: list[Plugin] | None = None,
) -> None:
self.name = name
self._level = parse_level(level)
self.transports: list[Transport] = list(transports) if transports else []
self.plugins: list[Plugin] = list(plugins) if plugins else []

@property
def level(self) -> Level:
Expand All@@ -25,17 +29,45 @@ def level(self) -> Level:
def set_level(self, level: int | str | Level) -> None:
self._level = parse_level(level)

def use(self, plugin: Plugin) -> Logger:
"""Register a plugin. Returns `self` so calls can be chained."""
self.plugins.append(plugin)
return self

def close(self) -> None:
"""Close every attached transport. Call on shutdown to flush buffered writes."""
for transport in self.transports:
transport.close()

def _notify_error(self, plugin: Plugin, exc: Exception, record: LogRecord) -> None:
# a broken error handler must not crash logging either
with contextlib.suppress(Exception):
plugin.on_error(exc, record)

def _log(self, level: Level, message: str, meta: dict[str, Any]) -> LogRecord | None:
if level < self._level:
return None
record = create_record(level=level, logger=self.name, message=message, meta=meta)

for plugin in self.plugins:
try:
result = plugin.before_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)
continue
if result is None:
return None
record = result

for transport in self.transports:
transport.write(transport.format(record), record)

for plugin in self.plugins:
try:
plugin.after_log(record)
except Exception as exc:
self._notify_error(plugin, exc, record)

return record

def trace(self, message: str, **meta: Any) -> LogRecord | None:
Expand Down
22 changes: 22 additions & 0 deletions logquill/plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from logquill.records import LogRecord


class Plugin:
"""Base class for the plugin pipeline: `before_log`, `after_log`, `on_error`.

Override only the hooks you need — the rest default to no-ops. A plugin
hook that raises cannot crash logging: the pipeline catches it, routes it
to `on_error`, and moves on.
"""

def before_log(self, record: LogRecord) -> LogRecord | None:
"""Return a (possibly modified) record, or `None` to drop it."""
return record

def after_log(self, record: LogRecord) -> None:
"""Called after the record has been dispatched to every transport."""

def on_error(self, exc: Exception, record: LogRecord) -> None:
"""Called when one of this plugin's own hooks raises."""
28 changes: 28 additions & 0 deletions logquill/redact_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
from __future__ import annotations

from collections.abc import Iterable

from logquill.plugin import Plugin
from logquill.records import LogRecord

DEFAULT_REDACTED_KEYS = frozenset({"password", "token", "secret", "api_key", "authorization"})


class RedactPlugin(Plugin):
"""Replaces sensitive `meta` values, matched by key (case-insensitive), with a placeholder."""

def __init__(
self,
keys: Iterable[str] = DEFAULT_REDACTED_KEYS,
replacement: str = "***",
) -> None:
self.keys = {key.lower() for key in keys}
self.replacement = replacement

def before_log(self, record: LogRecord) -> LogRecord | None:
meta = record["meta"]
record["meta"] = {
key: self.replacement if key.lower() in self.keys else value
for key, value in meta.items()
}
return record
20 changes: 20 additions & 0 deletions logquill/sampling_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from __future__ import annotations

import random
from typing import Callable

from logquill.plugin import Plugin
from logquill.records import LogRecord


class SamplingPlugin(Plugin):
"""Keeps roughly `rate` of records (0.0-1.0), dropping the rest."""

def __init__(self, rate: float, rng: Callable[[], float] | None = None) -> None:
if not 0.0 <= rate <= 1.0:
raise ValueError(f"rate must be between 0 and 1, got {rate!r}")
self.rate = rate
self._rng = rng or random.random

def before_log(self, record: LogRecord) -> LogRecord | None:
return record if self._rng() < self.rate else None
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line numberDiff line numberDiff line change
Expand Up@@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "logquill"
version = "0.1.2"
version = "0.1.3"
description = "A structured, leveled logging framework with pluggable transports and a plugin pipeline."
readme = "README.md"
license = "MIT"
Expand Down
20 changes: 20 additions & 0 deletions tests/test_context_plugin.py
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
from logquill.context_plugin import ContextPlugin
from logquill.logger import Logger


def test_injects_fixed_context_into_meta() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(service="api", env="prod")])

record = logger.info("hello", user_id=42)

assert record is not None
assert record["meta"] == {"service": "api", "env": "prod", "user_id": 42}


def test_call_site_meta_overrides_fixed_context() -> None:
logger = Logger("app.test", plugins=[ContextPlugin(env="prod")])

record = logger.info("hello", env="staging")

assert record is not None
assert record["meta"]["env"] == "staging"
Loading