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.
- _taskdependencygraph_version.py +1 -0
- taskdependencygraph/__init__.py +8 -0
- taskdependencygraph/models/__init__.py +24 -0
- taskdependencygraph/models/ids.py +38 -0
- taskdependencygraph/models/person.py +27 -0
- taskdependencygraph/models/task_dependency_edge.py +45 -0
- taskdependencygraph/models/task_dependency_update.py +61 -0
- taskdependencygraph/models/task_execution_status.py +37 -0
- taskdependencygraph/models/task_node.py +124 -0
- taskdependencygraph/models/task_node_as_artificial_endnode.py +25 -0
- taskdependencygraph/models/task_node_as_artificial_startnode.py +22 -0
- taskdependencygraph/plotting/__init__.py +11 -0
- taskdependencygraph/plotting/kroki.py +148 -0
- taskdependencygraph/plotting/protocols.py +43 -0
- taskdependencygraph/py.typed +2 -0
- taskdependencygraph/task_dependency_graph.py +591 -0
- taskdependencygraph-0.1.0.dist-info/METADATA +233 -0
- taskdependencygraph-0.1.0.dist-info/RECORD +19 -0
- taskdependencygraph-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version = "0.1.0"
|
|
@@ -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"]
|