This commit is contained in:
Alkis Plas 2026-01-21 13:16:57 +00:00 committed by GitHub
commit 6afc619b92
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 345 additions and 0 deletions

BIN
doc/recruitment.pdf Normal file

Binary file not shown.

23
doc/recruitment.txt Normal file
View File

@ -0,0 +1,23 @@
initial()
compound_state(Application)
state(A, Non Hire)
styles(node, [fontname=Helvetica, fontcolor=red])
state(B, New Hire,[entry/Candidate Hire, do/Add Employee])
compound_end()
state(C, Employee, [entry/Intake New Employee])
state(D, Retiree, [do/Employee Retirement])
state(E, Former Employee, [do/Terminate, exit/Archive])
final()
transition(initial, B, Accepts offer)
transition(initial, A, Rejects offer)
transition(B, C, Begins work)
transition(C, C, Boring Reality)
transition(C, D, Employee Retires, [color=blue])
transition(C, E, Employee Terminates, [color=red])
transition(D, final)

View File

@ -0,0 +1,90 @@
stategraph
========
Stategraph is a python script which allows the declarative specification and drawing of UML state diagrams.
How to use it:
--------
1) Use the commands stated below to declare a state diagram and save it in a text file.
2) Change your directory to the place where both the script and the text file are located
3) Run the script as stated below
4) At the test-output directory you will find the diagram in the format you provided.
State diagram commands
--------
state(id, name, events[])
-description:Creates a state
-arguments: id: Unique identifier for the state inside the source (mandatory).
name: Caption to be displayed (defaults to the state id).
events[]: Events of the state (optional).
transition(source, target, label, styling[])
-description:Creates a transition
-arguments: source: Source state identifier (mandatory).
target: Target state identifier (mandatory).
label: Caption to be displayed near the transition (optional).
styling[]: Array of styling attributes to be applied (optional).
initial()
-description:The initial state. To be referred as initial when referenced in a transition.
final()
-description:The final state. To be referred as final when referenced in a transition.
compound_state(name)
-description: Creates a subgraph which includes states and transitions
-arguments: name (optional)
compound_end()
-description: Ends the compound state
styles(element, styling[])
-description: Applies styling to the element specified
-arguments: element (mandatory), styling[] (mandatory)
note(id, note)
-description: Applies a note to the state specified
-arguments: node_id (mandatory), note (mandatory)
Running the script
------------
Run the script as stategraph.py [-o output_file] [-v] [input file]
1) If an output file is not specified a pdf is generated using
the name of the input file.
2) The -v parameter will open the diagram for you to view
example: Running the script as stategraph.py -recruitment.txt -v -o recruitment.png
will create a png file called recruitment in the test-output directory as well as
open the file right away.
Example
-------
A full example containing both the declaration and the resulting diagram can be found
under the stategraph example directory.
The example is called recruitment.txt and the generated pdf is called
recruitment.pdf
Support
-------
If you are having issues, please let us know.
Contact at: alkisplas@gmail.com

232
stategraph.py Normal file
View File

@ -0,0 +1,232 @@
#!/usr/bin/env python
from graphviz import Digraph
from pyparsing import *
import argparse
import os
# -----Parser-----
LP, RP, LB, RB = map(Suppress, "()[]") # those will be skipped when iterating
tokens = "+" + "'" + "=" + "/" + "_"
item = originalTextFor(OneOrMore(Word(alphanums + tokens))) # originalText keeps the whitespaces between words
table = LB + item + ZeroOrMore(Suppress(Literal(",")) + item) + RB # table: [item, item, ...]
argument = item | Group(table) # argument = item or [item, item, ...]
listOfItems = Optional(argument + ZeroOrMore(Suppress(Literal(",")) + argument)) # LoI: [argument, argument, ...]
sentence = item.setResultsName("action") + LP + listOfItems.setResultsName("args") + RP # sentence: item(LoI)
rule = OneOrMore(Group(sentence)) # rule: sentence, sentence, ...
def add_state(graph, args_list):
""" Creates the state based on the arguments given
:param args_list:
args_list[0]: Unique identifier for the state inside the source(MANDATORY).
args_list[1]: Caption to be displayed (defaults to the state id).
args_list[2]: Events of the state(OPTIONAL).
"""
if len(args_list) > 3 or len(args_list) == 0:
raise Exception("states must follow the pattern: (id (mandatory), name (mandatory), "
"events[](optional), style[](optional))")
else:
if len(args_list) == 2:
graph.node(args_list[0], args_list[1])
elif len(args_list) == 3:
graph.node(args_list[0], shape="record", label="<f0>" + args_list[1] + "|<f1>" +
'\\n'.join([str(lst) for lst in args_list[2]]))
def add_transition(graph, args_list):
""" Creates the transition based on the arguments given
:param args_list :
args_list[0]: Source state identifier(MANDATORY).
args_list[1]: Target state identifier(MANDATORY).
args_list[2]: Caption to be displayed near the transition(OPTIONAL).
args_list[3]: Any styling to be applied(OPTIONAL).
"""
if len(args_list) > 4 or len(args_list) < 2:
raise Exception("transitions must follow the pattern: "
"(source (mandatory), target (mandatory), label (optional), styling[] (optional))")
else:
if len(args_list) == 2:
graph.edge(args_list[0], args_list[1])
elif len(args_list) == 3:
graph.edge(args_list[0], args_list[1], args_list[2])
else:
# turns the args_list to a styling dictionary
styles = list_to_dict(args_list[3])
graph.edge(args_list[0], args_list[1], args_list[2], styles)
def apply_styles(graph, args_list):
element = args_list[0] # where the styling will be applied (graph, node, edge)
styles = list_to_dict(args_list[1])
if element == 'graph':
graph.graph_attr.update(styles)
elif element == 'node':
graph.node_attr.update(styles)
elif element == 'edge':
graph.edge_attr.update(styles)
else:
raise Exception("styling must be applied to either graphs, nodes or edges")
def add_note(graph, args_list):
note_name = "note" + args_list[0] # unique note name
graph.node(note_name, args_list[1], shape="note")
graph.edge(note_name, args_list[0], arrowhead="none", style="dashed")
def initial(graph):
graph.node("initial", shape="circle", style="filled", fillcolor="black", label="", width='0.3')
def final(graph):
graph.node("final", shape="doublecircle", style="filled", fillcolor="black", label="", width='0.3')
def compound_state(args_list):
g2 = Digraph('cluster_g2') # subgraph's name MUST start with cluster_
g2.body.extend(['rankdir=LR'])
g2.body.append('color=black')
g2.body.append('style=rounded')
if args_list:
label = args_list[0]
g2.body.append('labeljust=center')
g2.body.append('label="' + label + '"')
return g2
def list_to_dict(alist):
""" Takes the list that contains the styling arguments to be applied
and turns it to a dictionary which graphviz understands
"""
changed_dict = {}
for each_item in alist:
a = each_item.split("=")
changed_dict[a[0]] = a[1]
return changed_dict
def parse_and_draw(graph, script):
""" Parses every line of the script and calls
an add method depending on that line's action command
:param graph: current graph being used
:param script: txt file being parsed
"""
entry = graph
try:
for line in rule.parseString(script):
if line.action == "initial":
initial(graph)
if line.action == "final":
final(graph)
if line.action == "state":
try:
add_state(graph, line.args)
except Exception as e:
raise Exception(str(e))
if line.action == "transition":
try:
add_transition(graph, line.args)
except Exception as e:
raise Exception(str(e))
if line.action == "note":
add_note(graph, line.args)
if line.action == "styles":
apply_styles(graph, line.args)
if line.action == "compound_state":
graph = compound_state(line.args)
if line.action == "compound_end":
entry.subgraph(graph)
graph = entry
return graph
except Exception as e:
print "could not draw diagram because: " + str(e)
def render_graph(cmd_args, graph):
""" Renders the graph and generates the output files as specified by the user
If an output file is not specified. A pdf is generated using
the name of the input file.
The extension dot is erased because the format function can't handle it.
:param cmd_args: contains the input file, the viewing preference and the output file
:param graph: graph to render
"""
if cmd_args.output is not None:
filename, file_extension = os.path.splitext(cmd_args.output)
else:
filename = os.path.splitext(cmd_args.filename)[0]
file_extension = 'pdf'
graph.format = file_extension.replace('.', '')
graph.render('test-output/' + filename, view=cmd_args.view)
# parse cmd arguments
parser = argparse.ArgumentParser()
parser.add_argument("filename")
parser.add_argument("-v", "--view", action="store_true", default=False)
parser.add_argument("-o", "--output", type=str)
args = parser.parse_args()
with open(args.filename) as f:
content = f.read().splitlines()
script = "\n".join(content)
g1 = Digraph('g1', node_attr={'shape': 'box', 'style': 'rounded'})
g1.body.extend(['rankdir=LR']) # graph's direction left-->right
render_graph(args, parse_and_draw(g1, script))