subroutine 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- subroutine/__init__.py +25 -0
- subroutine/__main__.py +12 -0
- subroutine/addressing.py +49 -0
- subroutine/api/__init__.py +11 -0
- subroutine/api/admin.py +117 -0
- subroutine/api/agenda.py +115 -0
- subroutine/api/app.py +171 -0
- subroutine/api/comments.py +303 -0
- subroutine/api/concurrency.py +131 -0
- subroutine/api/dependencies.py +85 -0
- subroutine/api/documents.py +657 -0
- subroutine/api/events.py +204 -0
- subroutine/api/health.py +76 -0
- subroutine/api/identity.py +194 -0
- subroutine/api/meta.py +797 -0
- subroutine/api/middleware.py +86 -0
- subroutine/api/pagination.py +304 -0
- subroutine/api/problems.py +319 -0
- subroutine/api/projects.py +415 -0
- subroutine/api/query.py +156 -0
- subroutine/api/routing.py +181 -0
- subroutine/api/schemas.py +87 -0
- subroutine/api/security.py +140 -0
- subroutine/api/shaping.py +279 -0
- subroutine/api/subjects.py +66 -0
- subroutine/api/tasks.py +767 -0
- subroutine/api/tokens.py +298 -0
- subroutine/api/users.py +126 -0
- subroutine/api/workspaces.py +398 -0
- subroutine/auth.py +201 -0
- subroutine/cli/__init__.py +6 -0
- subroutine/cli/main.py +2087 -0
- subroutine/cli/personal.py +3890 -0
- subroutine/cli/topics.py +214 -0
- subroutine/clients/__init__.py +6 -0
- subroutine/clients/base.py +538 -0
- subroutine/clients/http.py +943 -0
- subroutine/clients/local.py +1357 -0
- subroutine/clients/opening.py +37 -0
- subroutine/config.py +736 -0
- subroutine/connections.py +430 -0
- subroutine/context.py +295 -0
- subroutine/credentials.py +350 -0
- subroutine/db/__init__.py +1 -0
- subroutine/db/backup.py +1045 -0
- subroutine/db/base.py +27 -0
- subroutine/db/migrate.py +294 -0
- subroutine/db/migrations/env.py +164 -0
- subroutine/db/migrations/script.py.mako +35 -0
- subroutine/db/migrations/versions/0c8f7a7027e6_refs_are_workspace_sequential_integers.py +215 -0
- subroutine/db/migrations/versions/233f898a2bee_instance_timezone_workspace_timezone_.py +58 -0
- subroutine/db/migrations/versions/2fee457e5b0b_initial_schema.py +615 -0
- subroutine/db/migrations/versions/547fe53b263c_an_event_carries_the_subject_it_.py +195 -0
- subroutine/db/migrations/versions/ea3e86ad12c4_workspace_slug_frees_on_soft_delete.py +45 -0
- subroutine/db/mixins.py +148 -0
- subroutine/db/models/__init__.py +17 -0
- subroutine/db/models/activity.py +147 -0
- subroutine/db/models/identity.py +262 -0
- subroutine/db/models/project.py +165 -0
- subroutine/db/models/system.py +53 -0
- subroutine/db/models/vocabulary.py +172 -0
- subroutine/db/models/work.py +477 -0
- subroutine/db/seed.py +514 -0
- subroutine/db/session.py +159 -0
- subroutine/db/transfer.py +313 -0
- subroutine/db/types.py +157 -0
- subroutine/directory.py +170 -0
- subroutine/domain/__init__.py +9 -0
- subroutine/domain/agenda.py +227 -0
- subroutine/domain/authentication.py +442 -0
- subroutine/domain/authorization.py +599 -0
- subroutine/domain/bootstrap.py +215 -0
- subroutine/domain/capture.py +536 -0
- subroutine/domain/comments.py +393 -0
- subroutine/domain/dates.py +317 -0
- subroutine/domain/documents.py +571 -0
- subroutine/domain/durations.py +191 -0
- subroutine/domain/events.py +198 -0
- subroutine/domain/hierarchy.py +213 -0
- subroutine/domain/instances.py +55 -0
- subroutine/domain/links.py +552 -0
- subroutine/domain/local.py +291 -0
- subroutine/domain/mentions.py +211 -0
- subroutine/domain/ordering.py +326 -0
- subroutine/domain/paging.py +52 -0
- subroutine/domain/patch.py +43 -0
- subroutine/domain/projects.py +629 -0
- subroutine/domain/readiness.py +153 -0
- subroutine/domain/refs.py +222 -0
- subroutine/domain/schedule.py +391 -0
- subroutine/domain/scoping.py +194 -0
- subroutine/domain/search.py +60 -0
- subroutine/domain/selection.py +198 -0
- subroutine/domain/tags.py +273 -0
- subroutine/domain/tasks.py +1169 -0
- subroutine/domain/text.py +104 -0
- subroutine/domain/tokens.py +138 -0
- subroutine/domain/users.py +270 -0
- subroutine/domain/versions.py +79 -0
- subroutine/domain/workspaces.py +571 -0
- subroutine/errors.py +590 -0
- subroutine/fanout.py +198 -0
- subroutine/mcp/__init__.py +15 -0
- subroutine/mcp/protocol.py +283 -0
- subroutine/mcp/session.py +78 -0
- subroutine/mcp/tools.py +780 -0
- subroutine/permissions.py +132 -0
- subroutine/py.typed +0 -0
- subroutine/views.py +1226 -0
- subroutine-0.1.0.dist-info/METADATA +362 -0
- subroutine-0.1.0.dist-info/RECORD +114 -0
- subroutine-0.1.0.dist-info/WHEEL +4 -0
- subroutine-0.1.0.dist-info/entry_points.txt +2 -0
- subroutine-0.1.0.dist-info/licenses/LICENSE +661 -0
subroutine/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Subroutine — project management for people and agents, in equal measure.
|
|
2
|
+
|
|
3
|
+
A self-hostable task and project tracker whose HTTP API, CLI and data model treat a
|
|
4
|
+
person and an AI agent as equally first-class users. See ``SPEC.md`` for the full
|
|
5
|
+
specification and ``MVP-PLAN.md`` for what is being built first.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib.metadata
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _installed_version () -> str:
|
|
12
|
+
"""Report the installed package version, or a placeholder when running from source."""
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
return importlib.metadata.version("subroutine")
|
|
16
|
+
|
|
17
|
+
except importlib.metadata.PackageNotFoundError:
|
|
18
|
+
return "0.0.0+unknown"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
__version__ = _installed_version()
|
|
22
|
+
|
|
23
|
+
#: The API version exposed at ``/v1`` and reported in ``X-Subroutine-Api-Version``.
|
|
24
|
+
#: This tracks the wire contract, not the package release.
|
|
25
|
+
API_VERSION = "1.0"
|
subroutine/__main__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""``python -m subroutine`` — the same command as the ``subroutine`` script.
|
|
2
|
+
|
|
3
|
+
Worth having for two reasons beyond tidiness. A virtualenv whose ``bin`` is not on the path
|
|
4
|
+
still has a working command, which is the situation in a container more often than not; and a
|
|
5
|
+
test that needs a *separate process* — one serving over a real socket — can start one without
|
|
6
|
+
knowing where the console script was installed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import subroutine.cli.main
|
|
10
|
+
|
|
11
|
+
if __name__ == "__main__":
|
|
12
|
+
subroutine.cli.main.main()
|
subroutine/addressing.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""What may be used as an identifier inside an address.
|
|
2
|
+
|
|
3
|
+
A project key becomes a path segment — ``/v1/projects/SR`` — and so does a task ref. Some
|
|
4
|
+
segments are already spoken for by an endpoint: ``/v1/tasks/search`` is the search
|
|
5
|
+
endpoint, not a task called ``search``. Anything that would land on one of those is
|
|
6
|
+
refused when it is created, because the alternative is a project that exists, is listed,
|
|
7
|
+
and cannot be opened (SPEC.md §8.1).
|
|
8
|
+
|
|
9
|
+
Deliberately free of any HTTP framework. The rule is enforced in the service layer, which
|
|
10
|
+
runs for the CLI as well, and a domain module reaching into the API package to find out
|
|
11
|
+
what a valid key is would have the dependency exactly the wrong way round.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
#: Words a literal route already claims in the task and project path spaces. Listed here
|
|
15
|
+
#: rather than derived from the routing table, because a key is refused at creation —
|
|
16
|
+
#: possibly by a CLI with no application built — and because the list is a promise about
|
|
17
|
+
#: future endpoints too: ``sync`` has no route yet, and reserving it now costs nothing
|
|
18
|
+
#: while un-reserving it later would cost somebody their project key.
|
|
19
|
+
RESERVED_PATH_WORDS = frozenset({"batch", "next", "parse", "search", "sync"})
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#: Words a *workspace* short name may not take, because a workspace slug became part of an
|
|
23
|
+
#: address in §13.7 — ``connection/workspace/ref``. Position tells a connection from a
|
|
24
|
+
#: workspace, so there is no structural ambiguity; what these prevent is the human kind. A
|
|
25
|
+
#: workspace called ``local`` beside the implicit ``local`` connection makes ``use local``
|
|
26
|
+
#: mean two things, and ``all``/``none``/``me`` read as instructions rather than places.
|
|
27
|
+
#:
|
|
28
|
+
#: Reserved now rather than later, on the same reasoning as :data:`RESERVED_PATH_WORDS`:
|
|
29
|
+
#: reserving costs nothing today and un-reserving would cost somebody their workspace name.
|
|
30
|
+
RESERVED_WORKSPACE_WORDS = frozenset(
|
|
31
|
+
{"all", "default", "here", "local", "me", "mine", "none", "self"}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def is_reserved_workspace_word (value: str) -> bool:
|
|
36
|
+
"""Report whether a workspace short name would be confusing as an address segment."""
|
|
37
|
+
|
|
38
|
+
return value.strip().lower() in RESERVED_WORKSPACE_WORDS
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_reserved_word (value: str) -> bool:
|
|
42
|
+
"""Report whether a value would be swallowed by a literal route.
|
|
43
|
+
|
|
44
|
+
Compared case-insensitively. Identifiers in a path resolve case-insensitively
|
|
45
|
+
(SPEC.md §8.1), so a project keyed ``SEARCH`` is reachable at the same address as the
|
|
46
|
+
search endpoint whether or not the letters match.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
return value.strip().lower() in RESERVED_PATH_WORDS
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The HTTP API.
|
|
2
|
+
|
|
3
|
+
Thin by design. Every rule about what may happen lives in ``subroutine.domain``, so that
|
|
4
|
+
the CLI and the API cannot come to different conclusions about the same request — the
|
|
5
|
+
routers translate between HTTP and the service layer and do nothing else (SPEC.md §8.1).
|
|
6
|
+
|
|
7
|
+
The application is built by a factory rather than created at import time. A module-level
|
|
8
|
+
application would open the user's real database as a side effect of importing this
|
|
9
|
+
package, which makes it impossible to test against a temporary one and surprising to
|
|
10
|
+
import for any other reason.
|
|
11
|
+
"""
|
subroutine/api/admin.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Operational endpoints for whoever runs the instance (SPEC.md §12.6).
|
|
2
|
+
|
|
3
|
+
**Backup is here and restore deliberately is not.** An agent about to attempt something bulk
|
|
4
|
+
should be able to snapshot first, which is why taking a backup is reachable over HTTP. Putting
|
|
5
|
+
one back replaces the database the serving process currently has open — an endpoint that pulls
|
|
6
|
+
the floor out from under its own request is not a feature, and §12.4's recovery property
|
|
7
|
+
depends on the administrative commands working when the service will *not* start. Restore is
|
|
8
|
+
``subroutine db restore``, and only that.
|
|
9
|
+
|
|
10
|
+
Both endpoints require ``instance:admin``, which no role may carry: it is held only by a
|
|
11
|
+
superuser, and a token still narrows it (§7.3).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import datetime
|
|
15
|
+
|
|
16
|
+
import fastapi
|
|
17
|
+
import pydantic
|
|
18
|
+
import sqlalchemy.orm
|
|
19
|
+
|
|
20
|
+
import subroutine.api.dependencies
|
|
21
|
+
import subroutine.api.routing
|
|
22
|
+
import subroutine.api.security
|
|
23
|
+
import subroutine.db.backup
|
|
24
|
+
import subroutine.domain.authorization
|
|
25
|
+
import subroutine.permissions
|
|
26
|
+
|
|
27
|
+
router = fastapi.APIRouter(
|
|
28
|
+
prefix="/v1/admin",
|
|
29
|
+
tags=["admin"],
|
|
30
|
+
route_class=subroutine.api.routing.Transactional,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class Backup(pydantic.BaseModel):
|
|
35
|
+
"""One copy of the database, described well enough to choose between several."""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
path: str
|
|
39
|
+
taken_at: datetime.datetime
|
|
40
|
+
schema_head: str
|
|
41
|
+
size_bytes: int
|
|
42
|
+
|
|
43
|
+
#: Null for the default instance, which has no profile name (SPEC.md §12.5).
|
|
44
|
+
profile: str | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Backups(pydantic.BaseModel):
|
|
48
|
+
"""Every backup this instance holds, newest first."""
|
|
49
|
+
|
|
50
|
+
items: list[Backup]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _rendered (backup: subroutine.db.backup.Backup) -> Backup:
|
|
54
|
+
"""Describe a backup for a caller, with the path as text rather than a ``Path``."""
|
|
55
|
+
|
|
56
|
+
return Backup(
|
|
57
|
+
name=backup.name,
|
|
58
|
+
path=str(backup.path),
|
|
59
|
+
taken_at=backup.taken_at,
|
|
60
|
+
schema_head=backup.schema_head,
|
|
61
|
+
size_bytes=backup.size_bytes,
|
|
62
|
+
profile=backup.profile,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@router.post("/backups", response_model=Backup, status_code=201)
|
|
67
|
+
def create_backup (
|
|
68
|
+
actor: subroutine.api.security.PrincipalDep,
|
|
69
|
+
settings: subroutine.api.dependencies.SettingsDep,
|
|
70
|
+
session: subroutine.api.dependencies.SessionDep,
|
|
71
|
+
keep: int | None = fastapi.Body(
|
|
72
|
+
None,
|
|
73
|
+
embed=True,
|
|
74
|
+
description="Afterwards, keep only this many of the newest backups.",
|
|
75
|
+
),
|
|
76
|
+
) -> Backup:
|
|
77
|
+
"""Take a datetime-stamped copy of the database and report where it went."""
|
|
78
|
+
|
|
79
|
+
subroutine.domain.authorization.authorize_instance(
|
|
80
|
+
actor, subroutine.permissions.INSTANCE_ADMIN
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
return _rendered(subroutine.db.backup.take(_engine_behind(session), settings, keep=keep))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _engine_behind (session: sqlalchemy.orm.Session) -> sqlalchemy.engine.Engine:
|
|
87
|
+
"""Return the engine this session ultimately talks through.
|
|
88
|
+
|
|
89
|
+
The engine behind the *session*, rather than a second one built from the configured URL: a
|
|
90
|
+
backup taken over a different connection than the application serves from is a backup of a
|
|
91
|
+
database that may not be the one being served.
|
|
92
|
+
|
|
93
|
+
``get_bind`` answers with an ``Engine`` normally and with a ``Connection`` when something
|
|
94
|
+
has bound one — which the test harness does, so that a request shares the test's
|
|
95
|
+
transaction. Both have an engine behind them and it is the same engine either way.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
bind = session.get_bind()
|
|
99
|
+
|
|
100
|
+
if isinstance(bind, sqlalchemy.engine.Connection):
|
|
101
|
+
return bind.engine
|
|
102
|
+
|
|
103
|
+
return bind
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@router.get("/backups", response_model=Backups)
|
|
107
|
+
def list_backups (
|
|
108
|
+
actor: subroutine.api.security.PrincipalDep,
|
|
109
|
+
settings: subroutine.api.dependencies.SettingsDep,
|
|
110
|
+
) -> Backups:
|
|
111
|
+
"""List the backups this instance holds, newest first."""
|
|
112
|
+
|
|
113
|
+
subroutine.domain.authorization.authorize_instance(
|
|
114
|
+
actor, subroutine.permissions.INSTANCE_ADMIN
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
return Backups(items=[_rendered(found) for found in subroutine.db.backup.catalogue(settings)])
|
subroutine/api/agenda.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""``GET /v1/agenda`` — "what am I doing today?" as one request.
|
|
2
|
+
|
|
3
|
+
The four buckets are §8.6's, and they are **disjoint by priority**: a task appears in
|
|
4
|
+
exactly one of them, so a client can render the whole thing without deduplicating and a
|
|
5
|
+
count means what it says. Overdue wins over today, today over upcoming, and anything with
|
|
6
|
+
no date at all falls to unscheduled.
|
|
7
|
+
|
|
8
|
+
Unlike ``GET /v1/tasks`` this spans every workspace the caller can read, because "what am I
|
|
9
|
+
doing today" is a question about a person's day rather than about a workspace — the dentist
|
|
10
|
+
appointment and the deployment are both in it (SPEC.md §13.7).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import datetime
|
|
14
|
+
import uuid
|
|
15
|
+
|
|
16
|
+
import fastapi
|
|
17
|
+
import sqlalchemy.orm
|
|
18
|
+
|
|
19
|
+
import subroutine.api.dependencies
|
|
20
|
+
import subroutine.api.query
|
|
21
|
+
import subroutine.api.routing
|
|
22
|
+
import subroutine.api.security
|
|
23
|
+
import subroutine.db.types
|
|
24
|
+
import subroutine.domain.agenda
|
|
25
|
+
import subroutine.domain.authentication
|
|
26
|
+
import subroutine.domain.instances
|
|
27
|
+
import subroutine.domain.schedule
|
|
28
|
+
import subroutine.domain.selection
|
|
29
|
+
import subroutine.domain.workspaces
|
|
30
|
+
import subroutine.views
|
|
31
|
+
|
|
32
|
+
router = fastapi.APIRouter(
|
|
33
|
+
prefix="/v1",
|
|
34
|
+
tags=["agenda"],
|
|
35
|
+
route_class=subroutine.api.routing.Transactional,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@router.get(
|
|
40
|
+
"/agenda",
|
|
41
|
+
summary="What am I doing today?",
|
|
42
|
+
dependencies=[subroutine.api.query.UnknownQueryDep],
|
|
43
|
+
)
|
|
44
|
+
def read (
|
|
45
|
+
actor: subroutine.api.security.PrincipalDep,
|
|
46
|
+
session: subroutine.api.dependencies.SessionDep,
|
|
47
|
+
date: datetime.date | None = fastapi.Query(
|
|
48
|
+
None, description="The day to build the agenda for. Defaults to today, in your zone."
|
|
49
|
+
),
|
|
50
|
+
timezone: str | None = fastapi.Query(
|
|
51
|
+
None, description="Interpret the day in this zone, overriding your own."
|
|
52
|
+
),
|
|
53
|
+
horizon_days: int | None = fastapi.Query(
|
|
54
|
+
None, ge=1, description="How far ahead 'upcoming' reaches."
|
|
55
|
+
),
|
|
56
|
+
unscheduled_limit: int = fastapi.Query(
|
|
57
|
+
subroutine.domain.agenda.DEFAULT_UNSCHEDULED_LIMIT,
|
|
58
|
+
ge=0,
|
|
59
|
+
description="How many undated tasks to list. The total is always reported.",
|
|
60
|
+
),
|
|
61
|
+
workspace_id: str | None = fastapi.Query(
|
|
62
|
+
None,
|
|
63
|
+
description="Narrow to one workspace, by id or short name. Defaults to all of them.",
|
|
64
|
+
),
|
|
65
|
+
) -> subroutine.views.Agenda:
|
|
66
|
+
"""Return today's work, in four disjoint buckets."""
|
|
67
|
+
|
|
68
|
+
now = subroutine.db.types.utcnow()
|
|
69
|
+
zone = subroutine.domain.schedule.zone_for(
|
|
70
|
+
user=actor.user,
|
|
71
|
+
instance=subroutine.domain.instances.get(session),
|
|
72
|
+
explicit=timezone,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
built = subroutine.domain.agenda.build(
|
|
76
|
+
session,
|
|
77
|
+
principal=actor,
|
|
78
|
+
workspace_ids=_scope(session, actor, workspace_id),
|
|
79
|
+
now=now,
|
|
80
|
+
timezone=zone,
|
|
81
|
+
date=date,
|
|
82
|
+
horizon_days=horizon_days,
|
|
83
|
+
unscheduled_limit=unscheduled_limit,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return subroutine.views.agenda(session, built)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _scope (
|
|
90
|
+
session: sqlalchemy.orm.Session,
|
|
91
|
+
actor: subroutine.domain.authentication.Principal,
|
|
92
|
+
wanted: str | None,
|
|
93
|
+
) -> list[uuid.UUID]:
|
|
94
|
+
"""Return the workspaces this agenda covers: one if asked for, otherwise all readable.
|
|
95
|
+
|
|
96
|
+
**Spanning everything is the default and stays the default** — "what am I doing today" is a
|
|
97
|
+
question about a person's day, and an agenda that silently covered one workspace would hide
|
|
98
|
+
the dentist behind a work backlog.
|
|
99
|
+
|
|
100
|
+
The filter exists because the reverse also bites: one instance holding a personal to-do list
|
|
101
|
+
*and* a project's backlog put seven undated project tasks above "buy salad" the first time
|
|
102
|
+
this project used itself. Every other listing already took ``workspace_id``; the agenda was
|
|
103
|
+
the only one that did not, so this is a consistency repair as much as a feature.
|
|
104
|
+
|
|
105
|
+
Resolved through ``selection.workspace`` like every other listing, so a short name works, a
|
|
106
|
+
token's pin still applies, and a workspace the caller cannot read reads as absent rather than
|
|
107
|
+
forbidden (§7.3a).
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
if wanted is not None:
|
|
111
|
+
return [subroutine.domain.selection.workspace(session, actor, requested=wanted).id]
|
|
112
|
+
|
|
113
|
+
return [
|
|
114
|
+
workspace.id for workspace in subroutine.domain.workspaces.readable(session, actor)
|
|
115
|
+
]
|
subroutine/api/app.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Building the application.
|
|
2
|
+
|
|
3
|
+
A factory, not a module-level application: importing this package should not open the
|
|
4
|
+
user's database, and a test needs an instance pointed at a temporary one. ``subroutine
|
|
5
|
+
serve`` and any ASGI server call :func:`create_app`.
|
|
6
|
+
|
|
7
|
+
The order of what happens here is load-bearing and is set out in :func:`create_app`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import typing
|
|
12
|
+
|
|
13
|
+
import fastapi
|
|
14
|
+
import fastapi.middleware.cors
|
|
15
|
+
import sqlalchemy.engine
|
|
16
|
+
import sqlalchemy.orm
|
|
17
|
+
|
|
18
|
+
import subroutine
|
|
19
|
+
import subroutine.api.admin
|
|
20
|
+
import subroutine.api.agenda
|
|
21
|
+
import subroutine.api.comments
|
|
22
|
+
import subroutine.api.documents
|
|
23
|
+
import subroutine.api.events
|
|
24
|
+
import subroutine.api.health
|
|
25
|
+
import subroutine.api.identity
|
|
26
|
+
import subroutine.api.meta
|
|
27
|
+
import subroutine.api.middleware
|
|
28
|
+
import subroutine.api.problems
|
|
29
|
+
import subroutine.api.projects
|
|
30
|
+
import subroutine.api.routing
|
|
31
|
+
import subroutine.api.tasks
|
|
32
|
+
import subroutine.api.tokens
|
|
33
|
+
import subroutine.api.users
|
|
34
|
+
import subroutine.api.workspaces
|
|
35
|
+
import subroutine.config
|
|
36
|
+
import subroutine.db.migrate
|
|
37
|
+
import subroutine.db.session
|
|
38
|
+
|
|
39
|
+
#: Shown at ``/docs`` and in the generated OpenAPI document. The audience is a developer
|
|
40
|
+
#: or an agent deciding whether this endpoint does what they need, so it points at the two
|
|
41
|
+
#: places that answer that rather than describing the product again.
|
|
42
|
+
DESCRIPTION = """
|
|
43
|
+
Project management for people and agents, in equal measure.
|
|
44
|
+
|
|
45
|
+
`GET /v1/meta` reports this installation's vocabulary — its statuses, link types, field
|
|
46
|
+
operators and date grammar — so a client can read them rather than assume them.
|
|
47
|
+
`GET /v1/docs/agent` is a guide written for an agent working through this API.
|
|
48
|
+
|
|
49
|
+
Errors are RFC 9457 problem documents and every one carries a stable `code`; the codes are
|
|
50
|
+
part of the public contract and are listed in `docs/errors.md`.
|
|
51
|
+
""".strip()
|
|
52
|
+
|
|
53
|
+
#: Every router, in the order they are registered — and that order is load-bearing, which
|
|
54
|
+
#: is why they are declared as data rather than as a run of ``include_router`` calls. A
|
|
55
|
+
#: router carrying literal sub-paths must come before one carrying ``{id_or_ref}`` in the
|
|
56
|
+
#: same space, and :func:`subroutine.api.routing.check` reads this list to enforce it.
|
|
57
|
+
ROUTERS: tuple[subroutine.api.routing.Mounting, ...] = (
|
|
58
|
+
("", subroutine.api.health.router),
|
|
59
|
+
("", subroutine.api.identity.router),
|
|
60
|
+
("", subroutine.api.workspaces.router),
|
|
61
|
+
("", subroutine.api.users.router),
|
|
62
|
+
("", subroutine.api.tokens.router),
|
|
63
|
+
("", subroutine.api.meta.router),
|
|
64
|
+
("", subroutine.api.agenda.router),
|
|
65
|
+
("", subroutine.api.tasks.router),
|
|
66
|
+
# The link sub-resources come after the routers whose paths they extend. They cannot
|
|
67
|
+
# shadow or be shadowed — `/{id_or_ref}/links` is longer than anything in either — but
|
|
68
|
+
# `routing.check` is what says so rather than anybody's reading of it.
|
|
69
|
+
("", subroutine.api.documents.task_links),
|
|
70
|
+
("", subroutine.api.projects.router),
|
|
71
|
+
("", subroutine.api.documents.router),
|
|
72
|
+
("", subroutine.api.documents.document_links),
|
|
73
|
+
# The comment sub-resources come after the routers whose paths they extend, like links.
|
|
74
|
+
("", subroutine.api.comments.task_comments),
|
|
75
|
+
("", subroutine.api.comments.project_comments),
|
|
76
|
+
("", subroutine.api.comments.document_comments),
|
|
77
|
+
("", subroutine.api.comments.router),
|
|
78
|
+
# The history sub-resources, likewise after the routers they extend.
|
|
79
|
+
("", subroutine.api.events.task_events),
|
|
80
|
+
("", subroutine.api.events.project_events),
|
|
81
|
+
("", subroutine.api.events.document_events),
|
|
82
|
+
("", subroutine.api.admin.router),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def create_app (
|
|
87
|
+
*,
|
|
88
|
+
settings: subroutine.config.Settings | None = None,
|
|
89
|
+
session_factory: sqlalchemy.orm.sessionmaker[sqlalchemy.orm.Session] | None = None,
|
|
90
|
+
) -> fastapi.FastAPI:
|
|
91
|
+
"""Build the application.
|
|
92
|
+
|
|
93
|
+
``session_factory`` is for tests, which supply one bound to a transaction they can roll
|
|
94
|
+
back. When it is not given the engine is built here from ``settings`` and disposed when
|
|
95
|
+
the application shuts down.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
resolved = settings or subroutine.config.load_settings()
|
|
99
|
+
|
|
100
|
+
application = fastapi.FastAPI(
|
|
101
|
+
title="Subroutine",
|
|
102
|
+
summary="Project management for people and agents, in equal measure.",
|
|
103
|
+
description=DESCRIPTION,
|
|
104
|
+
version=subroutine.API_VERSION,
|
|
105
|
+
openapi_url="/v1/openapi.json",
|
|
106
|
+
docs_url="/docs",
|
|
107
|
+
redoc_url="/redoc",
|
|
108
|
+
lifespan=_lifespan,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
application.state.settings = resolved
|
|
112
|
+
application.state.engine = None
|
|
113
|
+
application.state.session_factory = session_factory
|
|
114
|
+
|
|
115
|
+
# Read once. It comes from the migration scripts shipped in this package, which cannot
|
|
116
|
+
# change while the process runs, and the readiness check should not walk a directory
|
|
117
|
+
# every time a load balancer asks whether it is alive.
|
|
118
|
+
application.state.schema_head = subroutine.db.migrate.head_revision()
|
|
119
|
+
|
|
120
|
+
if session_factory is None:
|
|
121
|
+
engine = subroutine.db.session.create_engine(resolved.database_url)
|
|
122
|
+
|
|
123
|
+
application.state.engine = engine
|
|
124
|
+
application.state.session_factory = subroutine.db.session.create_session_factory(engine)
|
|
125
|
+
|
|
126
|
+
application.middleware("http")(subroutine.api.middleware.correlate)
|
|
127
|
+
|
|
128
|
+
if resolved.cors_origins:
|
|
129
|
+
# Only when configured. A browser is not the primary client here, and a default
|
|
130
|
+
# that allows credentialed cross-origin requests is a hole nobody asked for.
|
|
131
|
+
application.add_middleware(
|
|
132
|
+
fastapi.middleware.cors.CORSMiddleware,
|
|
133
|
+
allow_origins=resolved.cors_origins,
|
|
134
|
+
allow_credentials=True,
|
|
135
|
+
allow_methods=["*"],
|
|
136
|
+
allow_headers=["*"],
|
|
137
|
+
expose_headers=[
|
|
138
|
+
subroutine.api.middleware.REQUEST_ID_HEADER,
|
|
139
|
+
subroutine.api.middleware.API_VERSION_HEADER,
|
|
140
|
+
],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
subroutine.api.problems.install(application)
|
|
144
|
+
|
|
145
|
+
# Checked before anything is mounted, so a violation is a refusal to build rather than
|
|
146
|
+
# an application that starts with an endpoint nobody can reach (SPEC.md §8.1).
|
|
147
|
+
subroutine.api.routing.check(ROUTERS)
|
|
148
|
+
|
|
149
|
+
for prefix, router in ROUTERS:
|
|
150
|
+
application.include_router(router, prefix=prefix)
|
|
151
|
+
|
|
152
|
+
return application
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@contextlib.asynccontextmanager
|
|
156
|
+
async def _lifespan (application: fastapi.FastAPI) -> typing.AsyncIterator[None]:
|
|
157
|
+
"""Hold the application's own resources for as long as it is serving.
|
|
158
|
+
|
|
159
|
+
Only an engine this module built is disposed. A session factory handed in by a test
|
|
160
|
+
belongs to the test, and disposing its engine underneath it would break the fixture
|
|
161
|
+
that owns it.
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
yield
|
|
166
|
+
|
|
167
|
+
finally:
|
|
168
|
+
engine = application.state.engine
|
|
169
|
+
|
|
170
|
+
if isinstance(engine, sqlalchemy.engine.Engine):
|
|
171
|
+
engine.dispose()
|