taskdependencygraph 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.
@@ -0,0 +1 @@
1
+ version = "0.1.0"
@@ -0,0 +1,8 @@
1
+ """
2
+ taskdependencygraph is a library to model tasks and dependencies between tasks in a networkx DiGraph
3
+ and give estimates when which task will be done
4
+ """
5
+
6
+ from .task_dependency_graph import TaskDependencyGraph
7
+
8
+ __all__ = ["TaskDependencyGraph"]
@@ -0,0 +1,24 @@
1
+ """models are python objects which we use to model tasks, dependencies and the graph they form"""
2
+
3
+ from .ids import PersonId, RunGroupId, RunGroupPersonRelationId, RunId, TaskDependencyId, TaskId
4
+ from .person import Person
5
+ from .task_dependency_edge import TaskDependencyEdge
6
+ from .task_execution_status import TaskExecutionStatus
7
+ from .task_node import TaskNode
8
+ from .task_node_as_artificial_endnode import ID_OF_ARTIFICIAL_ENDNODE
9
+ from .task_node_as_artificial_startnode import ID_OF_ARTIFICIAL_STARTNODE
10
+
11
+ __all__ = [
12
+ "Person",
13
+ "RunId",
14
+ "RunGroupId",
15
+ "RunGroupPersonRelationId",
16
+ "TaskId",
17
+ "TaskDependencyId",
18
+ "PersonId",
19
+ "TaskNode",
20
+ "TaskDependencyEdge",
21
+ "TaskExecutionStatus",
22
+ "ID_OF_ARTIFICIAL_ENDNODE",
23
+ "ID_OF_ARTIFICIAL_STARTNODE",
24
+ ]
@@ -0,0 +1,38 @@
1
+ """
2
+ contains ID types we use to differentiate between UUIDs
3
+ """
4
+
5
+ import uuid
6
+ from typing import NewType
7
+
8
+ RunId = NewType("RunId", uuid.UUID)
9
+ """
10
+ technically unique ID of a run
11
+ """
12
+
13
+ RunGroupId = NewType("RunGroupId", uuid.UUID)
14
+ """
15
+ technically unique ID of a run group
16
+ """
17
+
18
+ RunGroupPersonRelationId = NewType("RunGroupPersonRelationId", uuid.UUID)
19
+ """
20
+ technically unique ID the relation between a run group and a person
21
+ """
22
+
23
+ TaskId = NewType("TaskId", uuid.UUID)
24
+ """
25
+ technically unique ID of a task
26
+ """
27
+
28
+ TaskDependencyId = NewType("TaskDependencyId", uuid.UUID)
29
+ """
30
+ technically unique ID of a task dependency
31
+ """
32
+
33
+ PersonId = NewType("PersonId", uuid.UUID)
34
+ """
35
+ technically unique ID of a person
36
+ """
37
+ # the __all__ fixes the mypy warning: Module "..." does not explicitly export attribute "TaskId"
38
+ __all__ = ["RunId", "RunGroupId", "RunGroupPersonRelationId", "TaskId", "TaskDependencyId", "PersonId"]
@@ -0,0 +1,27 @@
1
+ """
2
+ Person domain model
3
+ """
4
+
5
+ from uuid import UUID, uuid4
6
+
7
+ from pydantic import BaseModel, EmailStr, Field
8
+
9
+
10
+ # pylint: disable=too-few-public-methods
11
+ # pylint: disable=duplicate-code
12
+ class Person(BaseModel):
13
+ """
14
+ Entity 'person' includes anyone who uses the Cut Over Tool in whichever manner.
15
+ Persons are always humans. A person can act in multiple roles
16
+ (e.g. as assignee of one task and manager of another)
17
+ """
18
+
19
+ id: UUID = Field(default_factory=uuid4)
20
+ name: str
21
+ email: EmailStr
22
+ phone_number: str | None = None
23
+ slack_id: str | None = None
24
+ is_active: bool = True
25
+
26
+
27
+ __all__ = ["Person"]
@@ -0,0 +1,45 @@
1
+ """
2
+ task_dependency domain model object
3
+ """
4
+
5
+ from pydantic import BaseModel, ConfigDict
6
+
7
+ from taskdependencygraph.models.ids import TaskDependencyId, TaskId
8
+
9
+
10
+ class TaskDependencyEdge(BaseModel):
11
+ """
12
+ Task dependencies form the edges - i.e. interconnect task nodes - of the task dependency graph
13
+ """
14
+
15
+ model_config = ConfigDict(frozen=True)
16
+ # the Task has to be frozen/hashable to be used as a node in a networkx graph
17
+ # How to use model_config: https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.frozen
18
+ # NetworkX restrictions regarding "nodes have to be hashable":
19
+ # https://networkx.org/documentation/stable/reference/classes/generated/networkx.DiGraph.add_node.html
20
+
21
+ id: TaskDependencyId
22
+ """
23
+ Unique ID of this TaskDependency
24
+ """
25
+
26
+ task_predecessor: TaskId
27
+
28
+ """
29
+ Refers to the preceding task by means of its uuid
30
+ """
31
+
32
+ task_successor: TaskId
33
+ """
34
+ Refers to the succeeding task by means of its uuid
35
+ """
36
+
37
+ def to_dot(self) -> str:
38
+ """
39
+ returns a dot representation of this edge;
40
+ For details on the dot language see https://graphviz.org/doc/info/lang.html
41
+ """
42
+ return f'"{self.task_predecessor}" -> "{self.task_successor}"\n'
43
+
44
+
45
+ __all__ = ["TaskDependencyEdge"]
@@ -0,0 +1,61 @@
1
+ """
2
+ When using the Task Dependency Graph in an application, these models are helpful to give the user feedback on whether
3
+ the can or cannot add a task/node or dependency/edge to the graph.
4
+ """
5
+
6
+ from typing import Self
7
+
8
+ from pydantic import BaseModel, model_validator
9
+
10
+
11
+ class AddNodeToGraphPreviewResponse(BaseModel):
12
+ """
13
+ Response to the frontends' request to potentially add a node to the TDG.
14
+ It's named 'preview' because the node is not actually added to the TDG yet.
15
+ """
16
+
17
+ can_be_added: bool # we can 'translate' this to an 🟢🔴 icon in the FE/jinja template
18
+ """
19
+ true iff the node can be added to the TDG
20
+ """
21
+ error_message: str | None = None
22
+ """
23
+ error message if the node cannot be added to the TDG
24
+ """
25
+
26
+ @model_validator(mode="after")
27
+ def validate_there_is_an_error_message_if_necessary(self) -> Self:
28
+ """
29
+ Ensure that an error message is provided if the node cannot be added
30
+ """
31
+ if self.can_be_added is True or (self.can_be_added is False and self.error_message):
32
+ return self
33
+ raise ValueError("If the task can not be added, an error message must be provided")
34
+
35
+
36
+ class AddEdgeToGraphPreviewResponse(BaseModel):
37
+ """
38
+ response to the frontends' request to potentially add an edge to the TDG
39
+ It's named 'preview' because the edge is not actually added to the TDG yet.
40
+ """
41
+
42
+ can_be_added: bool # we can 'translate' this to an 🟢🔴 icon in the FE/jinja template
43
+ """
44
+ true iff the edge can be added to the TDG
45
+ """
46
+ error_message: str | None = None
47
+ """
48
+ error message if the edge cannot be added to the TDG
49
+ """
50
+
51
+ @model_validator(mode="after")
52
+ def validate_there_is_an_error_message_if_necessary(self) -> "Self":
53
+ """
54
+ Ensure that an error message is provided if the node cannot be added
55
+ """
56
+ if self.can_be_added is True or (self.can_be_added is False and self.error_message):
57
+ return self
58
+ raise ValueError("If the task can not be added, an error message must be provided")
59
+
60
+
61
+ __all__ = ["AddEdgeToGraphPreviewResponse", "AddNodeToGraphPreviewResponse"]
@@ -0,0 +1,37 @@
1
+ """contains the taskexecutionstatus enum"""
2
+
3
+ from enum import StrEnum
4
+
5
+
6
+ class TaskExecutionStatus(StrEnum):
7
+ """
8
+ The task_execution_status specifies which status the execution of one task has.
9
+ The TaskExecutionStatus class makes sure that there are only four possible values for the execution_status of
10
+ tasks, which don't report an error.
11
+ """
12
+
13
+ NOT_YET_REQUESTED = "NOT_YET_REQUESTED"
14
+ """
15
+ The execution of the task has not yet been requested.
16
+ """
17
+ REQUESTED = "REQUESTED"
18
+ """
19
+ The execution of the task has been requested, but the assignee hasn't reported yet
20
+ that s/he has started the task.
21
+ """
22
+ STARTED = "STARTED"
23
+ """
24
+ The execution of the task has been requested and the assignee has reported that s/he has started
25
+ executing the task
26
+ """
27
+ COMPLETED = "COMPLETED"
28
+ """
29
+ The assignee has reported that the execution of the task has been completed.
30
+ """
31
+ OBSOLETE = "OBSOLETE"
32
+ """
33
+ The assignee has reported that the task is obsolete and does not need to be executed anymore.
34
+ """
35
+
36
+
37
+ __all__ = ["TaskExecutionStatus"]
@@ -0,0 +1,124 @@
1
+ """
2
+ task domain model object
3
+ """
4
+
5
+ from datetime import datetime, timedelta
6
+ from typing import Annotated, Any, Literal
7
+
8
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
9
+
10
+ from taskdependencygraph.models.ids import TaskId
11
+ from taskdependencygraph.models.person import Person
12
+
13
+
14
+ class TaskNode(BaseModel):
15
+ """
16
+ This is a task domain model for task instances, that we use as nodes in the task dependency graph.
17
+ It is _not_ the task MODEL, which helps to create task instance in the DB.
18
+ It is also _not_ the task DTO, which passes through the API.
19
+ """
20
+
21
+ model_config = ConfigDict(frozen=True)
22
+ # the Task has to be frozen/hashable to be used as a node in a networkx graph
23
+ # How to use model_config: https://docs.pydantic.dev/latest/api/config/#pydantic.config.ConfigDict.frozen
24
+ # NetworkX restrictions regarding "nodes have to be hashable":
25
+ # https://networkx.org/documentation/stable/reference/classes/generated/networkx.DiGraph.add_node.html
26
+
27
+ # Q: Why the 2 IDs (internal and external)?
28
+ # A: The internal ID is to identify tasks technically (e.g. in the database).
29
+ # It may also be used by the frontend to update 1 specific task instance.
30
+ # In this case, the external ID is not unique, because tasks with the same ID may exist in different graphs.
31
+ # The external ID is for human users to identify tasks (within a graph).
32
+ # In Excel, both internal and external ID were the same, because 2 runs resulted in 2 Excel sheets.
33
+ # There was no conflict or ambiguity because those tasks which shared the same ID were spread over multiple
34
+ # Excel files.
35
+ # We don't have multiple databases or tables for tasks in different graphs.
36
+ # Hence, we need the 2 IDs.
37
+
38
+ id: TaskId
39
+ """
40
+ The ID is technically unique.
41
+ No two task instances ever share the same id.
42
+ Other than the external_id the ID is usually autogenerated and less handy for the user, because it's a GUID and not
43
+ a small readable string.
44
+ """
45
+ external_id: Annotated[str, Field(min_length=1)]
46
+ """
47
+ The external_id is unique within a single TaskDependencyGraph.
48
+ It is, what the user 'sees' as an ID (Example: 'MS1000').
49
+ But it's not unique enough to identify a task in the database.
50
+ The value is initially provided by the frontend/user.
51
+ """
52
+ name: Annotated[str, Field(min_length=1)]
53
+ """
54
+ The name is not necessarily unique.
55
+ Example: 'Beginn der Migrationsvorarbeiten'
56
+ The value is provided by the frontend.
57
+ """
58
+ phase: Annotated[str, Field(min_length=1)] | None = None
59
+ """
60
+ The task dependency graph is divided into phases by milestones.
61
+ Example: '1000'
62
+ The value may be provided by the frontend.
63
+ """
64
+ tags: list[Annotated[str, Field(min_length=1)]] | None = None
65
+ """
66
+ Tasks can have none, one or more tags. Tags are a possibility to group tasks.
67
+ Example: ['IS-U', 'Rechnungen'].
68
+ """
69
+ planned_duration: Annotated[timedelta, Field(min=timedelta(minutes=0))]
70
+ """
71
+ The planned_duration_minutes expresses how long the task execution is planned to last (in minutes).
72
+ Example: timedelta(minutes=600) (for 10h)
73
+ The value is provided by the frontend.
74
+ """
75
+ is_milestone: bool = False
76
+ """
77
+ Expresses if a task is a milestone.
78
+ Milestones are Pseudo-Tasks with duration = 0 and execution_status = COMPLETED.
79
+ It begins and ends a phase.
80
+ """
81
+
82
+ earliest_starttime: AwareDatetime | None = None
83
+ """
84
+ A possibility to set a fixed earliest start time for a task.
85
+ The task may only start at this datetime or later, even if predecessors finish earlier.
86
+ """
87
+
88
+ planned_duration_of_predecessor_tasks: Annotated[timedelta, Field(min=timedelta(minutes=0))] | None = None
89
+ """
90
+ The planned_duration_of_predecessor_tasks is the sum of the duration of the predecessor tasks.
91
+ The planned_duration_of_predecessor_tasks expresses, how long it takes from the beginning of the first task
92
+ to the task in question.
93
+ It is needed to calculate the the planned_start_time of the task in question, as the planned_start_time of the task
94
+ in question is calculated from the planned_start_time of the first task and the
95
+ planned_duration_of_predecessor_tasks.
96
+ Example: Example: timedelta(minutes=660) (for 11h)
97
+ This value is computed with the help of NetworkX.
98
+ """
99
+ planned_starting_time: datetime | None = None
100
+ """
101
+ The planned_start_time is the time when the execution of the task in question is planned to start.
102
+ It is calculated from the planned_start_time of the first task and the planned_duration_of_predecessor_tasks.
103
+ """
104
+
105
+ assignee: Person | None = None
106
+ """
107
+ The assignee of the task.
108
+ """
109
+
110
+ def to_dot(self, attributes: dict[Literal["label", "color"], Any]) -> str:
111
+ """
112
+ returns a dot representation of this task/node
113
+ For details on the dot language see https://graphviz.org/doc/info/lang.html
114
+ """
115
+ # we always pass along the id of the node with the dot string to that the svg element has the ID "svg-<id>"
116
+ # https://graphviz.org/docs/attrs/id/
117
+ result = f'"{self.id}"[id="svg-{self.id}";'
118
+ for key, value in attributes.items():
119
+ result += f'{key}="{value}";'
120
+ result += "];\n"
121
+ return result
122
+
123
+
124
+ __all__ = ["TaskNode"]
@@ -0,0 +1,25 @@
1
+ # pylint:disable=anomalous-backslash-in-string
2
+ """
3
+ The task_node_as_artificial_endnode is a task node, which is added to the task dependency graph due to
4
+ practical reasons: The artificial final task is needed in order to make the duration of the formerly last
5
+ node(s)/task(s) count, when identifying the critical path.
6
+
7
+ For more information: see app/core/task_dependency_graph/task_dependency_graph.py
8
+ --> add_artificial_nodes_and_edges
9
+ """
10
+
11
+
12
+ from datetime import timedelta
13
+ from uuid import UUID
14
+
15
+ from taskdependencygraph.models.ids import TaskId
16
+ from taskdependencygraph.models.task_node import TaskNode
17
+
18
+ ID_OF_ARTIFICIAL_ENDNODE: TaskId = TaskId(UUID("99999999-9999-9999-9999-999999999999"))
19
+ task_node_as_artificial_endnode = TaskNode(
20
+ id=ID_OF_ARTIFICIAL_ENDNODE,
21
+ external_id="__END__",
22
+ name="END",
23
+ planned_duration=timedelta(minutes=0),
24
+ )
25
+ __all__ = ["ID_OF_ARTIFICIAL_ENDNODE", "task_node_as_artificial_endnode"]
@@ -0,0 +1,22 @@
1
+ """
2
+ The task_node_as_artificial_startnode is a task node with a duration of 0 minutes, which is added to the
3
+ task dependency graph in order to have one single starting point - rather than one or more.
4
+ This single startnode makes calculations with Networkx easier, i.e. the calculation of the duration of predecessor
5
+ tasks on the critical path --> see app/core/task_dependency_graph/task_dependency_graph.py ->
6
+ calculate_planned_duration_of_predecessor_tasks_on_critical_path
7
+ """
8
+
9
+ from datetime import timedelta
10
+ from uuid import UUID
11
+
12
+ from taskdependencygraph.models.ids import TaskId
13
+ from taskdependencygraph.models.task_node import TaskNode
14
+
15
+ ID_OF_ARTIFICIAL_STARTNODE: TaskId = TaskId(UUID("11111111-1111-1111-1111-111111111111"))
16
+ task_node_as_artificial_startnode = TaskNode(
17
+ id=ID_OF_ARTIFICIAL_STARTNODE,
18
+ external_id="__START__",
19
+ name="START",
20
+ planned_duration=timedelta(minutes=0),
21
+ )
22
+ __all__ = ["ID_OF_ARTIFICIAL_STARTNODE", "task_node_as_artificial_startnode"]
@@ -0,0 +1,11 @@
1
+ """
2
+ The subpackage 'plotting' converts task dependency graphs to visual representations (images/SVGs).
3
+ We use kroki.io to do the plotting for us because this is way more powerful than matplotlib or builtin tools.
4
+ But instead of using the online kroki.io service, we host it locally in a docker-container (see docker-compose.yml).
5
+ Otherwise, we'd quickly run into rate limits.
6
+ """
7
+
8
+ from taskdependencygraph.plotting.kroki import KrokiClient, KrokiConfig
9
+ from taskdependencygraph.plotting.protocols import DotPlottable, MermaidPlottable
10
+
11
+ __all__ = ["DotPlottable", "MermaidPlottable", "KrokiClient", "KrokiConfig"]
@@ -0,0 +1,148 @@
1
+ """
2
+ Classes and methods to interact with kroki.io
3
+ """
4
+
5
+ import logging
6
+ import uuid
7
+ import xml.etree.ElementTree as ET
8
+
9
+ from aiohttp import ClientConnectorError, ClientResponseError, ClientSession, ClientTimeout
10
+ from pydantic import BaseModel, HttpUrl
11
+ from yarl import URL
12
+
13
+ from taskdependencygraph.plotting.protocols import PlotMode
14
+ from taskdependencygraph.task_dependency_graph import TaskDependencyGraph
15
+
16
+ _logger = logging.getLogger(__name__)
17
+
18
+
19
+ class KrokiConfig(BaseModel):
20
+ """
21
+ Configuration to connect with the kroki service
22
+ """
23
+
24
+ host: HttpUrl
25
+ """
26
+ host is the base/root URL of the kroki service (e.g. 'http://localhost:8123' or 'https://kroki.io')
27
+ """
28
+
29
+
30
+ def _replace_id_with_svg_id(svg: str) -> str:
31
+ """replaces the ids of all <rect>-tags with 'svg-<id>' to avoid id-clashes in the overall DOM"""
32
+ root = ET.fromstring(svg)
33
+ for rect in root.findall(".//{http://www.w3.org/2000/svg}rect"):
34
+ if "id" in rect.attrib:
35
+ try:
36
+ uuid_id = uuid.UUID(rect.get("id")) # raises a ValueError if the id is not a valid UUID
37
+ rect.set("id", f"svg-{uuid_id}")
38
+ except ValueError:
39
+ continue
40
+ ET.register_namespace("", "http://www.w3.org/2000/svg") # removes the annoying "ns0:" prefix
41
+ return ET.tostring(root, encoding="unicode", xml_declaration=True)
42
+
43
+
44
+ class KrokiClient:
45
+ """
46
+ A client to interact with the kroki service
47
+ """
48
+
49
+ def __init__(self, config: KrokiConfig):
50
+ """initialize the client with a configuration"""
51
+ self._config = config
52
+ self._session: ClientSession | None = None
53
+ _logger.info("Instantiated KrokiClient with server_url %s", str(self._config.host))
54
+
55
+ async def _get_session(self) -> ClientSession:
56
+ """
57
+ returns a client session (that may be reused or newly created)
58
+ reusing the same (threadsafe) session will be faster than re-creating a new session for every request.
59
+ see https://docs.aiohttp.org/en/stable/http_request_lifecycle.html#how-to-use-the-clientsession
60
+ """
61
+ if self._session is None or self._session.closed:
62
+ _logger.info("creating new session")
63
+ self._session = ClientSession(
64
+ timeout=ClientTimeout(60),
65
+ raise_for_status=True,
66
+ )
67
+ else:
68
+ _logger.log(5, "reusing aiohttp session") # log level 5 is half as "loud" logging.DEBUG
69
+ return self._session
70
+
71
+ async def close_session(self) -> None:
72
+ """
73
+ closes the client session
74
+ """
75
+ if self._session is not None and not self._session.closed:
76
+ _logger.info("Closing aiohttp session")
77
+ await self._session.close()
78
+ self._session = None
79
+
80
+ async def is_ready(self) -> bool:
81
+ """
82
+ returns true, iff kroki is available
83
+ """
84
+ try:
85
+ simple_svg = await self._plot_dot_as_svg('digraph G{"A";"B";A->B}')
86
+ return simple_svg is not None
87
+ except Exception: # pylint:disable=broad-except
88
+ _logger.exception("The is_ready check failed of the kroki client failed")
89
+ return False
90
+
91
+ async def _plot_dot_as_svg(self, dot_string: str) -> str:
92
+ payload = {"diagram_source": dot_string, "diagram_type": "graphviz", "output_format": "svg"}
93
+ session = await self._get_session()
94
+ request_uuid = uuid.uuid4()
95
+ svg_url = URL(str(self._config.host)) / "graphviz" / "svg"
96
+ _logger.debug("[%s] Send SVG request to kroki service", request_uuid)
97
+ try:
98
+ response = await session.post(svg_url, json=payload)
99
+ except ClientConnectorError:
100
+ _logger.exception("Failed to connect to kroki. Is it actually running at '%s'?", self._config.host)
101
+ raise
102
+ except ClientResponseError as cre:
103
+ if cre.status == 400:
104
+ _logger.warning(
105
+ "The kroki service returned a 400 Bad Request. Your dot is probably invalid: %s", dot_string
106
+ )
107
+ raise
108
+ _logger.debug("[%s] Received response from kroki service; Status code %i", request_uuid, response.status)
109
+ svg = await response.text()
110
+ return svg
111
+
112
+ async def _plot_mermaid_as_svg(self, mermaid_str: str) -> str:
113
+ payload = {"diagram_source": mermaid_str, "diagram_type": "mermaid", "output_format": "svg"}
114
+ session = await self._get_session()
115
+ request_uuid = uuid.uuid4()
116
+ svg_url = URL(str(self._config.host)) / "mermaid" / "svg" % {"html-labels": "true"}
117
+ _logger.debug("[%s] Send SVG request to kroki service", request_uuid)
118
+ try:
119
+ response = await session.post(svg_url, json=payload)
120
+ except ClientConnectorError:
121
+ _logger.exception("Failed to connect to kroki. Is it actually running at '%s'?", self._config.host)
122
+ raise
123
+ except ClientResponseError as cre:
124
+ if cre.status == 400:
125
+ _logger.warning(
126
+ "The kroki service returned a 400 Bad Request. Your dot is probably invalid: %s", mermaid_str
127
+ )
128
+ raise
129
+ _logger.debug("[%s] Received response from kroki service; Status code %i", request_uuid, response.status)
130
+ response_body = await response.text()
131
+ return _replace_id_with_svg_id(response_body)
132
+
133
+ async def plot_as_svg(self, tdg: TaskDependencyGraph, mode: PlotMode = "gantt") -> str:
134
+ """
135
+ returns an XML/SVG string of the task dependency graph
136
+ """
137
+ match mode:
138
+ case "dot":
139
+ dot_string = tdg.to_dot()
140
+ return await self._plot_dot_as_svg(dot_string)
141
+ case "gantt":
142
+ mermaid_gantt_str = tdg.to_mermaid_gantt()
143
+ return await self._plot_mermaid_as_svg(mermaid_gantt_str)
144
+ case _:
145
+ raise ValueError(f"Unknown mode '{mode}'")
146
+
147
+
148
+ __all__ = ["KrokiClient", "KrokiConfig"]
@@ -0,0 +1,43 @@
1
+ """
2
+ interface like descriptions for any kind of plotting "client" library
3
+ """
4
+
5
+ from typing import Literal, Protocol
6
+
7
+ PlotMode = Literal["dot", "gantt"]
8
+
9
+
10
+ class DotPlottable(Protocol):
11
+ """
12
+ something that can be plotted using Dot/GraphViz
13
+ """
14
+
15
+ def to_dot(self) -> str:
16
+ """
17
+ returns the dot-representation of this object as plain string
18
+ """
19
+
20
+
21
+ class MermaidPlottable(Protocol):
22
+ """
23
+ something that can be plotted using Mermaid
24
+ """
25
+
26
+ def to_mermaid_gantt(self) -> str:
27
+ """
28
+ returns the mermaid-gantt chart representation of this object as plain string
29
+ """
30
+
31
+
32
+ class Plotter(Protocol):
33
+ """
34
+ a plotter is something that converts a task dependency graph to a visual representation
35
+ """
36
+
37
+ async def plot_as_svg(self, graph: MermaidPlottable | DotPlottable, mode: PlotMode) -> str:
38
+ """
39
+ Plots something plottable as SVG; returns the SVG as XML string
40
+ """
41
+
42
+
43
+ __all__ = ["DotPlottable", "MermaidPlottable", "Plotter"]
@@ -0,0 +1,2 @@
1
+ # This file marks mypackage as PEP561 compatible.
2
+ # Further reading: https://mypy.readthedocs.io/en/stable/installed_packages.html#creating-pep-561-compatible-packages