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.
Files changed (114) hide show
  1. subroutine/__init__.py +25 -0
  2. subroutine/__main__.py +12 -0
  3. subroutine/addressing.py +49 -0
  4. subroutine/api/__init__.py +11 -0
  5. subroutine/api/admin.py +117 -0
  6. subroutine/api/agenda.py +115 -0
  7. subroutine/api/app.py +171 -0
  8. subroutine/api/comments.py +303 -0
  9. subroutine/api/concurrency.py +131 -0
  10. subroutine/api/dependencies.py +85 -0
  11. subroutine/api/documents.py +657 -0
  12. subroutine/api/events.py +204 -0
  13. subroutine/api/health.py +76 -0
  14. subroutine/api/identity.py +194 -0
  15. subroutine/api/meta.py +797 -0
  16. subroutine/api/middleware.py +86 -0
  17. subroutine/api/pagination.py +304 -0
  18. subroutine/api/problems.py +319 -0
  19. subroutine/api/projects.py +415 -0
  20. subroutine/api/query.py +156 -0
  21. subroutine/api/routing.py +181 -0
  22. subroutine/api/schemas.py +87 -0
  23. subroutine/api/security.py +140 -0
  24. subroutine/api/shaping.py +279 -0
  25. subroutine/api/subjects.py +66 -0
  26. subroutine/api/tasks.py +767 -0
  27. subroutine/api/tokens.py +298 -0
  28. subroutine/api/users.py +126 -0
  29. subroutine/api/workspaces.py +398 -0
  30. subroutine/auth.py +201 -0
  31. subroutine/cli/__init__.py +6 -0
  32. subroutine/cli/main.py +2087 -0
  33. subroutine/cli/personal.py +3890 -0
  34. subroutine/cli/topics.py +214 -0
  35. subroutine/clients/__init__.py +6 -0
  36. subroutine/clients/base.py +538 -0
  37. subroutine/clients/http.py +943 -0
  38. subroutine/clients/local.py +1357 -0
  39. subroutine/clients/opening.py +37 -0
  40. subroutine/config.py +736 -0
  41. subroutine/connections.py +430 -0
  42. subroutine/context.py +295 -0
  43. subroutine/credentials.py +350 -0
  44. subroutine/db/__init__.py +1 -0
  45. subroutine/db/backup.py +1045 -0
  46. subroutine/db/base.py +27 -0
  47. subroutine/db/migrate.py +294 -0
  48. subroutine/db/migrations/env.py +164 -0
  49. subroutine/db/migrations/script.py.mako +35 -0
  50. subroutine/db/migrations/versions/0c8f7a7027e6_refs_are_workspace_sequential_integers.py +215 -0
  51. subroutine/db/migrations/versions/233f898a2bee_instance_timezone_workspace_timezone_.py +58 -0
  52. subroutine/db/migrations/versions/2fee457e5b0b_initial_schema.py +615 -0
  53. subroutine/db/migrations/versions/547fe53b263c_an_event_carries_the_subject_it_.py +195 -0
  54. subroutine/db/migrations/versions/ea3e86ad12c4_workspace_slug_frees_on_soft_delete.py +45 -0
  55. subroutine/db/mixins.py +148 -0
  56. subroutine/db/models/__init__.py +17 -0
  57. subroutine/db/models/activity.py +147 -0
  58. subroutine/db/models/identity.py +262 -0
  59. subroutine/db/models/project.py +165 -0
  60. subroutine/db/models/system.py +53 -0
  61. subroutine/db/models/vocabulary.py +172 -0
  62. subroutine/db/models/work.py +477 -0
  63. subroutine/db/seed.py +514 -0
  64. subroutine/db/session.py +159 -0
  65. subroutine/db/transfer.py +313 -0
  66. subroutine/db/types.py +157 -0
  67. subroutine/directory.py +170 -0
  68. subroutine/domain/__init__.py +9 -0
  69. subroutine/domain/agenda.py +227 -0
  70. subroutine/domain/authentication.py +442 -0
  71. subroutine/domain/authorization.py +599 -0
  72. subroutine/domain/bootstrap.py +215 -0
  73. subroutine/domain/capture.py +536 -0
  74. subroutine/domain/comments.py +393 -0
  75. subroutine/domain/dates.py +317 -0
  76. subroutine/domain/documents.py +571 -0
  77. subroutine/domain/durations.py +191 -0
  78. subroutine/domain/events.py +198 -0
  79. subroutine/domain/hierarchy.py +213 -0
  80. subroutine/domain/instances.py +55 -0
  81. subroutine/domain/links.py +552 -0
  82. subroutine/domain/local.py +291 -0
  83. subroutine/domain/mentions.py +211 -0
  84. subroutine/domain/ordering.py +326 -0
  85. subroutine/domain/paging.py +52 -0
  86. subroutine/domain/patch.py +43 -0
  87. subroutine/domain/projects.py +629 -0
  88. subroutine/domain/readiness.py +153 -0
  89. subroutine/domain/refs.py +222 -0
  90. subroutine/domain/schedule.py +391 -0
  91. subroutine/domain/scoping.py +194 -0
  92. subroutine/domain/search.py +60 -0
  93. subroutine/domain/selection.py +198 -0
  94. subroutine/domain/tags.py +273 -0
  95. subroutine/domain/tasks.py +1169 -0
  96. subroutine/domain/text.py +104 -0
  97. subroutine/domain/tokens.py +138 -0
  98. subroutine/domain/users.py +270 -0
  99. subroutine/domain/versions.py +79 -0
  100. subroutine/domain/workspaces.py +571 -0
  101. subroutine/errors.py +590 -0
  102. subroutine/fanout.py +198 -0
  103. subroutine/mcp/__init__.py +15 -0
  104. subroutine/mcp/protocol.py +283 -0
  105. subroutine/mcp/session.py +78 -0
  106. subroutine/mcp/tools.py +780 -0
  107. subroutine/permissions.py +132 -0
  108. subroutine/py.typed +0 -0
  109. subroutine/views.py +1226 -0
  110. subroutine-0.1.0.dist-info/METADATA +362 -0
  111. subroutine-0.1.0.dist-info/RECORD +114 -0
  112. subroutine-0.1.0.dist-info/WHEEL +4 -0
  113. subroutine-0.1.0.dist-info/entry_points.txt +2 -0
  114. 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()
@@ -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
+ """
@@ -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)])
@@ -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()