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
18 changes: 15 additions & 3 deletions docs/design/API_DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ the unprefixed name for the I/O classes. Types not defined here are in [§3 Item
```python
class AsyncWorkspace:
@classmethod
def from_config(cls, path: str | Path | None = None, *, section: str | None = None) -> Self: ...
async def from_config(cls, path: str | Path | None = None, *, section: str | None = None) -> Self: ...
def __init__(self, backends: Iterable[AsyncBackend] = ()) -> None: ...

backends: Sequence[AsyncBackend]
Expand All @@ -243,8 +243,13 @@ class AsyncWorkspace:
async def close(self) -> None: ...
```

`from_config` is specified by [roadmap 1.4](ROADMAP.md#14-configuration-and-credentials); this document only fixes that it exists and returns
a workspace.
`from_config` reads the shared configuration file through
[calendaring-config](https://github.com/pycalendar/calendaring-config),
whose `docs/format.md` specifies it ([roadmap 1.4](ROADMAP.md#14-configuration-and-credentials)).
It makes one backend per section the name stands for, with the section's
name as the backend's id. It is a coroutine in async mode, since
connecting may need the network; any problem with the file is a
`ConfigurationError`.

**Fan-out returns partial results.** `Workspace.collections` asks every
backend, and `Workspace.search` every collection, and both return a
Expand Down Expand Up @@ -289,6 +294,13 @@ class AsyncBackend:
- Credentials are never required in the URL or in plain-text configuration
([roadmap 1.4](ROADMAP.md#14-configuration-and-credentials)'s keyring work); `connect`'s keyword arguments are for programmatic
use.
- Open: which configuration keys a backend ignores. The shared file keeps
keys such as `features`, `calendar_name`, `priority` and
`testing_allowed`, and `Workspace.from_config()` passes every key to
`connect()`; today a key the backend does not take is a
`ConfigurationError` naming the section. Decided with the CalDAV
backend ([roadmap 2.1](ROADMAP.md#21-caldav-backend)), the first one
whose sections carry such keys.

### 2.3 Collection

Expand Down
32 changes: 16 additions & 16 deletions docs/design/CONFIGURATION_PROPOSAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@
**Status:** proposal for the pycal team, drafted by Claude Opus 5.5 on
2026-10-09. The format (F2 below) was chosen on 2026-10-14 by the author
and Sashank, in [PR 6](https://github.com/pycalendar/calendaring/pull/6).
Where the parser lives is not decided: option B is proposed, and has
Sashank's support.
The parser lives in a package of its own, option C, decided on 2026-10-18:
[calendaring-config](https://github.com/pycalendar/calendaring-config),
whose `docs/format.md` is now the specification of the format.

**Feeds:** [roadmap 1.4](ROADMAP.md#14-configuration-and-credentials) and
[API spec §10, Q8](API_DESIGN.md#10-open-questions)
Expand Down Expand Up @@ -198,18 +199,13 @@ handling.
[D1](PRIOR_ART_AND_DECISIONS.md#d1-packaging-principle) is the principle
that logic which isn't CalDAV logic doesn't belong in `caldav`.

**Proposal: B now, and C only if the team wants calendaring-jmap to read
the shared file without calendaring.** B costs nothing today:
`calendaring` already depends on `caldav`, and caldav's parser is public
(`expand_config_section`, `config_section`, `read_config`,
`expand_env_vars`). What makes a later move to C cheap is that **the format
is documented once, as a specification**, not defined by whichever parser
reads it. If the code later moves from caldav into a package, no user's
file changes.

Option B leaves calendaring-jmap close to reading the shared file on its
own: its flat loader already reads one section, so pointing it at a named
section is all it needs for files without `inherits` or meta-sections.
**Decided: C**, as [calendaring-config](https://github.com/pycalendar/calendaring-config).
B was proposed first, but `calendaring` does not depend on `caldav`,
and every change to the parser would have waited for a caldav release.
A package of its own is released at calendaring's pace, and caldav
moves to it when it is ready. The format is documented once, as a
specification (calendaring-config's `docs/format.md`), not defined by
whichever parser reads it, so no user's file changes when caldav switches.

The number of packages matters. Every new one is another release, CI
setup and version matrix for the same few people. C is worth that only if
Expand All @@ -221,14 +217,18 @@ one flat section.
1. Should `calendaring-jmap` on its own read the shared file, with
meta-sections and inheritance? If yes, go with parser option C; if no, B.
Sashank: no, so B; calendaring passes the section to calendaring-jmap,
which keeps its simple file for standalone use.
which keeps its simple file for standalone use. C was chosen anyway,
for release reasons (see above), so calendaring-jmap can read the
full file through calendaring-config if it ever wants to.
2. The file name. caldav's `~/.config/caldav/calendar.conf` is an odd home
for JMAP and GitHub sections. **Proposal:** `~/.config/pycal/calendar.yaml`
as the first place searched, with caldav's current paths still read as
fallbacks, so that nobody has to move a file. caldav does not search
`~/.config/pycal/` today, so this is one of the caldav changes above.
calendaring-config searches it first, as proposed, until the team
decides otherwise.
3. Are `_password_command` and `_password_keyring` the right two
mechanisms, or is one of them enough?
mechanisms, or is one of them enough? calendaring-config has both.

---

Expand Down
25 changes: 17 additions & 8 deletions docs/design/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,17 +384,26 @@ is deliberately the command-line half only. See
therefore has a committed downstream consumer and should not be trimmed.

**Tasks:**
- [ ] Config file format and schema, supporting multiple accounts and backends
- [ ] Config file discovery (XDG paths, explicit path, environment)
- [ ] Credential handling: keyring integration, and never requiring secrets in
- [x] Config file format and schema, supporting multiple accounts and backends
- [x] Config file discovery (XDG paths, explicit path, environment)
- [x] Credential handling: keyring integration, and never requiring secrets in
plaintext config
- [ ] Settle the shared file with the caldav and calendaring-jmap
maintainers: see [CONFIGURATION_PROPOSAL.md](CONFIGURATION_PROPOSAL.md)
- [ ] Check existing conventions first — `caldav`, `plann`, `vdirsyncer`, `khal`, etc — rather
maintainers: see [CONFIGURATION_PROPOSAL.md](CONFIGURATION_PROPOSAL.md).
The parser is now a package of its own,
[calendaring-config](https://github.com/pycalendar/calendaring-config);
caldav has yet to adopt it
([caldav issue #786](https://github.com/python-caldav/caldav/issues/786)),
and the file name is still open.
- [x] Check existing conventions first — `caldav`, `plann`, `vdirsyncer`, `khal`, etc — rather
than inventing a fifth format (probably build on the caldav format)
- [ ] Leave room for backends beyond the funded ones without format changes:
GitHub first, then any plugin backend
- [ ] Document the format
- [x] Leave room for backends beyond the funded ones without format changes:
GitHub first, then any plugin backend. A section's `kind` names any
registered backend; a GitHub section still needs `url` until
`connect()` makes it optional, a gap the
[configuration proposal](CONFIGURATION_PROPOSAL.md#the-format-f2-a-kind-key-with-the-keys-backendconnect-takes)
notes.
- [x] Document the format: calendaring-config's `docs/format.md`

---

Expand Down
1 change: 1 addition & 0 deletions news/+from-config.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``Workspace.from_config()`` reads the configuration file shared with caldav and calendaring-jmap, and makes one backend per section. The format, its discovery and the credential lookup (a command, or the system keyring) come from the new calendaring-config package. In async mode it is a coroutine: ``await AsyncWorkspace.from_config()``.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"calendaring-config>=0.1",
"icalendar>=7.3",
"icalendar-searcher>=1.1",
"recurring-ical-events>=3.8",
Expand Down
62 changes: 59 additions & 3 deletions src/calendaring/_async/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,17 @@
from __future__ import annotations

from collections.abc import Iterable, Sequence
from contextlib import suppress
from functools import partial
from pathlib import Path
from typing import Any, Self
from urllib.parse import urlsplit, urlunsplit

import calendaring_config
from icalendar_searcher import Searcher

from .._concurrency import agather
from ..errors import AmbiguousError, CalendaringError, NotFoundError
from ..errors import AmbiguousError, CalendaringError, ConfigurationError, NotFoundError
from ..loss import LossPolicy
from ..types import MultiResult
from .backend import AsyncBackend
Expand All @@ -26,14 +29,67 @@
__all__ = ["AsyncWorkspace"]


def _without_credentials(url: str) -> str:
"""``url`` with any ``user:password@`` taken out, for error messages."""
parts = urlsplit(url)
if "@" not in parts.netloc:
return url
return urlunsplit(parts._replace(netloc=parts.netloc.rpartition("@")[2]))


def _section_params(conn: calendaring_config.Connection) -> tuple[str, str, dict[str, Any]]:
"""The section name, its url and the rest of ``connect()``'s keywords, the id included."""
params = dict(conn.params)
url = params.pop("url", None)
if not isinstance(url, str) or not url:
raise ConfigurationError(f"section {conn.name}: url must be a non-empty string")
default_id = f"environment-{conn.kind}" if conn.name == "environment" else conn.name
params.setdefault("id", default_id)
return conn.name, url, params


class AsyncWorkspace:
backends: Sequence[AsyncBackend]
loss: LossPolicy | None
verify: bool | None

@classmethod
def from_config(cls, path: str | Path | None = None, *, section: str | None = None) -> Self:
raise NotImplementedError("the configuration file is roadmap 1.4")
async def from_config(cls, path: str | Path | None = None, *, section: str | None = None) -> Self:
"""One backend per section ``section`` stands for, each with the section's name as its id.

The file, its discovery and its secrets are calendaring-config's
(its ``docs/format.md``); every problem with them, or with connecting
a section, is a ``ConfigurationError`` naming the section. Connections
from the environment get the id ``environment-<kind>``.
"""
try:
connections = calendaring_config.load(path, section)
if not connections:
file = calendaring_config.find_config_file(path)
except calendaring_config.ConfigError as e:
raise ConfigurationError(str(e)) from e
if not connections:
if file is None:
raise ConfigurationError("no configuration file found, and no URL in the environment")
raise ConfigurationError(f"{file} has no default section, or the one named has no enabled section")
sections = [_section_params(conn) for conn in connections]
ids = [params["id"] for _, _, params in sections]
if duplicate := next((i for i in ids if ids.count(i) > 1), None):
raise ConfigurationError(f"two sections give the backend id {duplicate}")
backends: list[AsyncBackend] = []
try:
for (name, url, params), conn in zip(sections, connections, strict=True):
try:
backends.append(await AsyncBackend.connect(url, kind=conn.kind, **params))
except Exception as e:
message = str(e).replace(url, _without_credentials(url))
raise ConfigurationError(f"section {name}: {message}") from None
except BaseException:
for backend in backends:
with suppress(Exception):
await backend.close()
raise
return cls(backends)

def __init__(
self, backends: Iterable[AsyncBackend] = (), *, loss: LossPolicy | None = None, verify: bool | None = None
Expand Down
60 changes: 58 additions & 2 deletions src/calendaring/_sync/workspace.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading