├── .github └── workflows │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── dbdiag ├── __init__.py ├── __main__.py ├── cli.py ├── constants.py ├── history.py ├── model.py ├── parser.py ├── render.py ├── spans.py └── units.py ├── docs ├── linearizability_1.2.a.svg ├── linearizability_1.2.b.svg ├── linearizability_1.2.c.svg ├── linearizability_1.2.d.svg ├── ophistory_all.svg └── ophistory_all.txt ├── poetry.lock ├── pyproject.toml └── tests ├── __init__.py ├── test_parser.py └── test_spans.py /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish 2 | 3 | on: 4 | push: 5 | branches: 6 | - release 7 | 8 | jobs: 9 | release: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v3 15 | 16 | - name: Upgrade pip 17 | run: | 18 | pip install pip 19 | pip --version 20 | 21 | - name: Install Poetry 22 | run: | 23 | pip install poetry 24 | poetry --version 25 | 26 | - name: Set up Python 27 | uses: actions/setup-python@v4 28 | with: 29 | python-version: '3.12' 30 | cache: 'poetry' 31 | 32 | - name: Install dependencies 33 | run: poetry install --no-interaction 34 | 35 | - name: Build wheel 36 | run: poetry build 37 | 38 | - name: Run Tests 39 | run: poetry run pytest 40 | 41 | - name: Generate release tag 42 | id: tag 43 | run: echo "release_tag=$(git rev-parse HEAD | head -c 8)" >> $GITHUB_OUTPUT 44 | 45 | - name: Upload Release Asset 46 | uses: softprops/action-gh-release@v2 47 | env: 48 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 49 | with: 50 | tag_name: ${{ steps.tag.outputs.release_tag }} 51 | files: dist/*.whl 52 | draft: false 53 | prerelease: false 54 | 55 | - name: Remove other releases 56 | uses: sgpublic/delete-release-action@v1.1 57 | with: 58 | release-drop: true 59 | release-keep-count: 2 60 | release-drop-tag: false 61 | env: 62 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 63 | 64 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.envrc 2 | /.direnv/ 3 | /dist/ 4 | __pycache__ 5 | /venv/ 6 | /zig/ 7 | /.vscode/ 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # dbdiag 2 | 3 | Diagrams as text tools for databases and distributed systems 4 | 5 | [_ophistory_](#ophistory) lets you write a simple text file of operations starting and ending in order, and renders them like so:[^1] 6 | 7 | 8 | 9 | ## ophistory 10 | 11 | This tool is used to make diagrams for showing concurrent operations, modeled after those seen in [Linearizability: A correctness condition for concurrent objects](https://cs.brown.edu/~mph/HerlihyW90/p463-herlihy.pdf). 12 | 13 | It may be invoked as `ophistory.py [--embed] -o `. 14 | 15 | By default, the SVG uses the `ch` and `em` units to scale with the text size of the document. This does not work well with any viewers or tools other than a webbrowser, so `--embed` causes only `px` to be used as units, and the font size fixed to `12px` so that lines match up with text. 16 | 17 | The input file follows a similar syntax as the paper as well. Each line has three parts: 18 | 19 | ` [:.]? [KEY]` 20 | 21 | Where `<>` is required and `[]` is optional. 22 | 23 | The `ACTOR` exists to group spans together. It should either be the object being operated upon, on the entity performing the operations. `OPERATION` is the text that will be displayed above a span. If the text has spaces, but double quotes around it. `KEY` can be any identifier, and the first time that a key is seen on a line, the line is interpreted as the start of the span. The next line with the same `KEY` denotes the end of the span, and then the `KEY` is forgotten. 24 | 25 | The operation `END` is special, and not displayed. The span will be shown with just one operation text centered over the span instead. If an operation starts and immediately finishes, you may omit the `KEY`. This is semantically equivalent to writing an immediately following line with an `END` operation. 26 | 27 | The operation `EVENT` is special, and will display a dot along the operation line that the given point. This can be used to signify when the operation atomically occurred between its start and end, if needed. 28 | 29 | To reproduce the four FIFO queue histories from _S1.2 Motivation_: 30 | 31 | 32 | 33 | 34 | 44 | 45 | 46 | 55 | 56 | 57 | 64 | 65 | 66 | 78 | 79 | 80 | 81 |
35 |

36 | A: E(x) a
37 | B: E(y)
38 | A: END a
39 | B: D(x)
40 | A: D(y)
41 | A: E(z)
42 | 
43 |
47 |

48 | A: E(x)
49 | B: E(y) a
50 | A: D(y) a
51 | B: END a
52 | A: END a
53 | 
54 |
58 |

59 | A: E(x) a
60 | B: D(x)
61 | A: END a
62 | 
63 |
67 |

68 | A: E(x) a
69 | B: E(y) a
70 | A: END a
71 | B: END a
72 | A: D(y) a
73 | C: D(x) a
74 | A: END a
75 | C: END a
76 | 
77 |
82 | 83 | [^1]: If you have e.g. log files from a system, and you're trying to build a visualization of the operations that ran in your system, please consider [shiviz](https://bestchai.bitbucket.io/shiviz/) instead. 84 | 85 | 86 | -------------------------------------------------------------------------------- /dbdiag/__init__.py: -------------------------------------------------------------------------------- 1 | from .spans import to_span_svg 2 | from .history import to_history_svg -------------------------------------------------------------------------------- /dbdiag/__main__.py: -------------------------------------------------------------------------------- 1 | from . import cli 2 | 3 | cli.main() -------------------------------------------------------------------------------- /dbdiag/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import functools 3 | import argparse 4 | from . import constants 5 | from . import spans 6 | from . import history 7 | 8 | 9 | def make_main(args_fn): 10 | def outer(body_fn): 11 | @functools.wraps(body_fn) 12 | def inner(args=None): 13 | args = args or args_fn().parse_args() 14 | 15 | if args.debug: 16 | constants.DEBUG = True 17 | if args.guidelines: 18 | constants.GUIDELINES = True 19 | if args.embed: 20 | constants.EMBED = True 21 | 22 | with open(args.file) as f: 23 | text_input = f.read() 24 | 25 | svg = body_fn(text_input, args) 26 | 27 | if args.output is None or args.output == '-': 28 | sys.stdout.write(svg) 29 | elif args.output.endswith('.svg'): 30 | with open(args.output, 'w') as f: 31 | f.write(svg) 32 | elif args.output.endswith('.png'): 33 | # A yet-to-be-released version of cairosvg is required to correctly 34 | # render the SVGs produced, so make it a runtime requirement. 35 | from cairosvg import svg2png 36 | svg2png(bytestring=svg, write_to=args.output) 37 | return inner 38 | return outer 39 | 40 | def common_parser(): 41 | parser = argparse.ArgumentParser(add_help=False) 42 | parser.add_argument('--debug', action='store_true', help='print out each intermediate step') 43 | parser.add_argument('--guidelines', action='store_true', help='add extra lines to debug alignment issues') 44 | parser.add_argument('--embed', action='store_true', help='only use 12px font and px units') 45 | return parser 46 | 47 | def parse_history_args(parser=None): 48 | parser = parser or argparse.ArgumentParser(parents=[common_parser()]) 49 | parser.add_argument('file', help='file of operations') 50 | parser.add_argument('-o', '--output', help='output file path') 51 | parser.add_argument('--serialize-at', default='start', help='start or end') 52 | return parser 53 | 54 | 55 | @make_main(parse_history_args) 56 | def main_history(text_input, args): 57 | return history.to_history_svg(text_input, serialize_at=args.serialize_at) 58 | 59 | def parse_spans_args(parser=None): 60 | parser = parser or argparse.ArgumentParser(parents=[common_parser()]) 61 | parser.add_argument('file', help='file of operations') 62 | parser.add_argument('-o', '--output', help='output file path') 63 | return parser 64 | 65 | @make_main(parse_spans_args) 66 | def main_spans(text_input, args): 67 | return spans.to_span_svg(text_input) 68 | 69 | def parse_main_args(parser=None): 70 | common = common_parser() 71 | parser = parser or argparse.ArgumentParser(parents=[common]) 72 | subparsers = parser.add_subparsers(required=True) 73 | 74 | history_parser = subparsers.add_parser('history', parents=[common]) 75 | history_parser.set_defaults(main_func=main_history) 76 | parse_history_args(history_parser) 77 | 78 | spans_parser = subparsers.add_parser('spans', parents=[common]) 79 | spans_parser.set_defaults(main_func=main_spans) 80 | parse_spans_args(spans_parser) 81 | 82 | return parser 83 | 84 | def main(): 85 | args = parse_main_args().parse_args() 86 | args.main_func(args) 87 | -------------------------------------------------------------------------------- /dbdiag/constants.py: -------------------------------------------------------------------------------- 1 | DEBUG = False 2 | GUIDELINES = False 3 | EMBED = False -------------------------------------------------------------------------------- /dbdiag/history.py: -------------------------------------------------------------------------------- 1 | import enum 2 | from . import parser 3 | from . import model 4 | from . import spans 5 | from . import constants 6 | from . import render 7 | 8 | class SERIALIZE_AT(enum.StrEnum): 9 | START = "start" 10 | END = "end" 11 | 12 | def statements_to_operations(statements : list[list[parser.Statement]], serialize_at=SERIALIZE_AT.START) -> model.Chart: 13 | chart = spans.statements_to_spans(statements) 14 | 15 | operations = [] 16 | for span in chart.spans: 17 | text = span.text[0] if span.text[1] is None else '->'.join(span.text) 18 | slot = span.start if serialize_at==SERIALIZE_AT.START else span.end 19 | slot = span.eventpoint if span.eventpoint is not None else slot 20 | operations.append(model.Operation(span.actor, slot, slot, text)) 21 | 22 | # Renumber the operations so that the start/ends are adjacent 23 | operations.sort(key=lambda x: x.start) 24 | for idx, op in enumerate(operations): 25 | op.start = idx * 2 26 | op.end = idx * 2 + 1 27 | 28 | # We flattened all slots to 0, so compress the actor also 29 | for actor in chart.actors: 30 | actor.slots = 1 31 | 32 | return model.Chart(chart.actors, operations, []) 33 | 34 | def to_history_svg(text_input, embed=None, serialize_at=SERIALIZE_AT.START): 35 | if embed is True or embed is False: 36 | constants.EMBED = embed 37 | try: 38 | statements = parser.parse(text_input) 39 | except RuntimeError as e: 40 | return str(e) 41 | if not statements: 42 | return "" 43 | if constants.DEBUG: print(statements) 44 | chart = statements_to_operations(statements, serialize_at=serialize_at) 45 | if constants.DEBUG: print(chart) 46 | render.chart_assign_xs(chart) 47 | if constants.DEBUG: print(chart) 48 | svg = render.chart_to_svg(chart) 49 | if constants.DEBUG: print(svg) 50 | return svg -------------------------------------------------------------------------------- /dbdiag/model.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import dataclasses 3 | from typing import Optional 4 | from . import units 5 | 6 | @dataclasses.dataclass 7 | class Actor(object): 8 | name : str 9 | slots : units.Slot 10 | x : units.Ch = None 11 | y : units.Px = None 12 | height : units.Px = None 13 | 14 | @dataclasses.dataclass 15 | class Operation(object): 16 | actor : str 17 | start : int 18 | end : int 19 | text : str 20 | height : int = 0 21 | eventpoint : Optional[int] = None 22 | x1 : Optional[units.Ch] = None 23 | x2 : Optional[units.Ch] = None 24 | event_x : Optional[units.Ch] = None 25 | slot : Optional[units.Slot] = None 26 | y : Optional[units.Px] = None 27 | 28 | OUTER_BUFFER = units.Ch(2) 29 | 30 | def width(self) -> units.Ch: 31 | return units.Ch(len(self.text)) 32 | 33 | @dataclasses.dataclass 34 | class Span(object): 35 | actor : str 36 | start : int 37 | end : int 38 | height : int 39 | text : tuple[Optional[str], Optional[str]] 40 | eventpoint : Optional[int] 41 | x1 : Optional[units.Ch] = None 42 | x2 : Optional[units.Ch] = None 43 | event_x : Optional[units.Ch] = None 44 | slot : Optional[units.Slot] = None 45 | y : Optional[units.Px] = None 46 | 47 | OUTER_BUFFER = units.Ch(4) 48 | 49 | def width(self) -> units.Ch: 50 | (left, right) = self.text 51 | chars = len(left or "") + len(right or "") 52 | both = left and right 53 | return units.Ch(chars) + (units.INNER_INNER_BUFFER if both else 0) + units.INNER_BUFFER * 2 54 | 55 | @dataclasses.dataclass 56 | class Arrow(object): 57 | actor : str 58 | start : int 59 | end : int 60 | x1 : Optional[units.Ch] = None 61 | x2 : Optional[units.Ch] = None 62 | slot1 : Optional[units.Slot] = None 63 | slot2 : Optional[units.Slot] = None 64 | y1 : Optional[units.Px] = None 65 | y2 : Optional[units.Px] = None 66 | 67 | def width(self) -> units.Ch: 68 | return 0 69 | 70 | @dataclasses.dataclass 71 | class Chart(object): 72 | actors : list[Actor] 73 | spans : list[Span] 74 | cross : list[Span] 75 | -------------------------------------------------------------------------------- /dbdiag/parser.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import re 3 | from enum import Enum 4 | from typing import NamedTuple, Optional, TypeAlias, List 5 | 6 | #### Parser 7 | 8 | ''' 9 | [ 10 | A -> B: RPC .B 11 | A -> C: RPC .C 12 | ] 13 | [ 14 | A <- B: Reply .B 15 | A <- C: Reply .C 16 | ] 17 | ''' 18 | 19 | class Statement(NamedTuple): 20 | actor: str 21 | arrow: Optional[str] 22 | dest: Optional[str] 23 | op: str 24 | key: Optional[str] 25 | 26 | Grouping : TypeAlias = List[Statement] 27 | AST : TypeAlias = List[Grouping | Statement] 28 | 29 | def compose(*args): 30 | def fn(text): 31 | state = {} 32 | for parser in args: 33 | result = parser(text) 34 | if result is None: 35 | return None 36 | else: 37 | v, text = result 38 | state.update(v) 39 | return state, text 40 | return fn 41 | 42 | def choose(*args): 43 | def fn(text): 44 | for parser in args: 45 | result = parser(text) 46 | if result is not None: 47 | return result 48 | return None 49 | return fn 50 | 51 | def optional(parser): 52 | def fn(text): 53 | result = parser(text) 54 | if result is None: 55 | return {}, text 56 | else: 57 | return result 58 | return fn 59 | 60 | 61 | ACTORTEXT = r'"[^"]*"|[a-zA-Z0-9]*' 62 | TEXT = r'"[^"]+"|[a-zA-Z0-9_(){}\[\],.]+' 63 | 64 | def make_consumer(regex, key): 65 | rgx = re.compile(regex) 66 | def consumer(text): 67 | m = rgx.match(text) 68 | if m: 69 | return {key: m.group(key)} if key else {}, text[m.end():] 70 | else: 71 | return None 72 | consumer.__name__ = 'consume_' + key 73 | return consumer 74 | 75 | def consume_actor(key): 76 | return make_consumer(f'^(?P<{key}>{ACTORTEXT}) *', key) 77 | def consume_text(key): 78 | return make_consumer(f'^(?P<{key}>{TEXT}) *', key) 79 | consume_arrow = make_consumer(r'^(?P(->|<-|-x|x-)) *', 'arrow') 80 | consume_separator = make_consumer(r'^(?P[:.]) *', 'sep') 81 | consume_eol = make_consumer(r'^(?P#.*)?$', 'eol') 82 | consume_grouping = make_consumer(r'^(?P[\[\]]) *', 'grouping') 83 | 84 | parse_action = compose( 85 | consume_actor('source'), 86 | optional(compose(consume_arrow, consume_actor('dest'))), 87 | consume_separator, 88 | consume_text('op'), 89 | optional(consume_text('key')), 90 | consume_eol) 91 | parse_grouping = compose( 92 | consume_grouping, 93 | consume_eol) 94 | 95 | parser = choose(parse_grouping, parse_action) 96 | 97 | 98 | def parse_statements(text : str) -> AST: 99 | """Parse a text file of statements into List[Statement]. 100 | 101 | TEXT := "[^"]+" # Quoted strings get " stripped 102 | | [a-zA-Z0-9_(){},.]+ # Omit " for anything identifier-like 103 | COMMENT := #.* # '#' is still for comments 104 | ARROW := <- | -> | -x | x- # Direction of communication 105 | SEPARATOR := : | . # a foo, a: foo() or a.foo are all fine 106 | statement := TEXT (ARROW TEXT)? SEPARATOR? TEXT TEXT? 107 | GROUPING := [ | ] # Concurrent events are in []'s 108 | LINE := NOTHING 109 | | COMMENT 110 | | GROUPING 111 | | statement COMMENT? 112 | """ 113 | 114 | statements = [] 115 | grouplist = None 116 | for line in text.splitlines(): 117 | line = line.strip('\n') 118 | if not line or line.startswith('#'): 119 | continue 120 | 121 | result = parser(line) 122 | if result is None: 123 | raise RuntimeError('Parse Failure: Line `{line}` must be of the form `actor: op key`.') 124 | result, _ = result 125 | 126 | if result.get('grouping') == '[': 127 | if grouplist is not None: 128 | raise RuntimeError('Groupings [] cannot be nested.') 129 | grouplist = [] 130 | continue 131 | if result.get('grouping') == ']': 132 | if grouplist is None: 133 | raise RuntimeError('Unbalanced []. Terminating grouping that was not started.') 134 | statements.append(grouplist) 135 | grouplist = None 136 | continue 137 | 138 | opname = result['op'] 139 | if opname == 'END': 140 | opname = None 141 | if opname == 'EVENT': 142 | # TODO: There's probably some fancier way to have sentinels 143 | opname = 'EVENT' 144 | opname = opname.strip('"') if opname else None 145 | statement = Statement(result['source'], result.get('arrow'), result.get('dest'), opname, result.get('key')) 146 | (grouplist if grouplist is not None else statements).append(statement) 147 | if grouplist is not None: 148 | raise RuntimeError('EOF with unbalanced []. Terminating grouping that was not started.') 149 | return statements 150 | 151 | def unsugar_statements(statements : AST) -> AST: 152 | ops : list[list[Statement]] = [] 153 | 154 | # Desugar raw statements into a single statement group 155 | for op in statements: 156 | if not isinstance(op, list): 157 | ops.append([op]) 158 | else: 159 | ops.append(op) 160 | 161 | def generate_key_fn(): 162 | counter = -1 163 | def fn(): 164 | nonlocal counter 165 | counter += 1 166 | return '__' + str(counter) 167 | return fn 168 | generate_key = generate_key_fn() 169 | 170 | # Desugar key-less statements into a start immediately followed by an end. 171 | for gidx, group in enumerate(ops): 172 | short_ops = [] 173 | for opidx, op in enumerate(group): 174 | if op.key is None: 175 | newkey = generate_key() 176 | newstart = op._replace(key=newkey) 177 | newend = newstart._replace(op=None) 178 | short_ops.append(newend) 179 | ops[gidx][opidx] = newstart 180 | if short_ops: 181 | ops.insert(gidx+1, short_ops) 182 | return ops 183 | 184 | def parse(text): 185 | raw_ast = parse_statements(text) 186 | return unsugar_statements(raw_ast) -------------------------------------------------------------------------------- /dbdiag/render.py: -------------------------------------------------------------------------------- 1 | import abc 2 | import textwrap 3 | import enum 4 | import dataclasses 5 | from typing import Optional 6 | from . import units 7 | from . import model 8 | from .units import * 9 | 10 | 11 | def chart_assign_xs_old(chart : model.Chart) -> model.Chart: 12 | base_heights = {} 13 | current_height = 0 14 | for actor in chart.actors: 15 | base_heights[actor.name] = current_height 16 | current_height += int(actor.slots) 17 | 18 | for span in chart.spans: 19 | span.x1 = units.Ch(span.start) * OUTER_BUFFER 20 | span.x2 = span.x1 + span.width() 21 | if span.eventpoint: 22 | span.event_x = units.Ch(span.eventpoint) * OUTER_BUFFER 23 | span.slot = units.Slot(base_heights[span.actor] + span.height) 24 | 25 | made_change = True 26 | while made_change: 27 | made_change = False 28 | for span in chart.spans: 29 | beforeevent = afterevent = None 30 | for other in chart.spans: 31 | if other.start < span.start and span.x1 < (other.x1 + OUTER_BUFFER): 32 | made_change = True 33 | span.x1 = other.x1 + OUTER_BUFFER 34 | span.x2 = max(span.x2, span.x1 + span.width()) 35 | if other.end < span.start and span.x1 < (other.x2 + OUTER_BUFFER): 36 | made_change = True 37 | span.x1 = other.x2 + OUTER_BUFFER 38 | span.x2 = max(span.x2, span.x1 + span.width()) 39 | if other.end < span.end and span.x2 < (other.x2 + OUTER_BUFFER): 40 | made_change = True 41 | span.x2 = other.x2 + OUTER_BUFFER 42 | lkj = [['start', 'x1'], ['eventpoint', 'event_x'], ['end', 'x2']] 43 | for idxattr, xattr in lkj: 44 | if span.start-1 == getattr(other, idxattr) and span.x1 > getattr(other, xattr) + OUTER_BUFFER: 45 | made_change = True 46 | span.x1 = getattr(other, xattr) + OUTER_BUFFER 47 | span.x2 = span.x1 + span.width() 48 | if span.eventpoint: 49 | if other.start == span.eventpoint-1: 50 | beforeevent = other.x1 51 | if other.end == span.eventpoint-1: 52 | beforeevent = other.x2 53 | if other.eventpoint == span.eventpoint-1: 54 | beforeevent = other.event_x 55 | if other.start == span.eventpoint+1: 56 | afterevent = other.x1 57 | if other.end == span.eventpoint+1: 58 | afterevent = other.x2 59 | if other.eventpoint == span.eventpoint+1: 60 | afterevent = other.event_x 61 | if span.eventpoint: 62 | if beforeevent is None or afterevent is None: 63 | made_change = True 64 | elif span.event_x != (beforeevent + afterevent)/2: 65 | made_change = True 66 | span.event_x = (beforeevent + afterevent)/2 67 | 68 | return model.Chart(chart.actors, chart.spans, chart.cross) 69 | 70 | def chart_assign_xs_generic(chart : model.Chart) -> model.Chart: 71 | DEBUG_THIS = True 72 | base_heights = {} 73 | current_height = 0 74 | for actor in chart.actors: 75 | base_heights[actor.name] = current_height 76 | current_height += int(actor.slots) 77 | 78 | posxattrs = [['start', 'x1'], ['eventpoint', 'event_x'], ['end', 'x2']] 79 | 80 | for span in chart.spans: 81 | for posattr, xattr in posxattrs: 82 | if getattr(span, posattr) is not None: 83 | setattr(span, xattr, units.Ch(getattr(span, posattr)) * OUTER_BUFFER) 84 | span.x2 = max(span.x2, span.x1 + span.width()) 85 | span.slot = units.Slot(base_heights[span.actor] + span.height) 86 | 87 | made_change = True 88 | while made_change: 89 | made_change = False 90 | for lhs in chart.spans: 91 | for rhs in chart.spans: 92 | for lhsposattr, lhsxattr in posxattrs: 93 | if getattr(lhs, lhsposattr) is None: 94 | continue 95 | for rhsposattr, rhsxattr in posxattrs: 96 | if getattr(rhs, rhsposattr) is None: 97 | continue 98 | if getattr(lhs, lhsposattr) < getattr(rhs, rhsposattr) and getattr(lhs, lhsxattr) + lhs.OUTER_BUFFER > getattr(rhs, rhsxattr): 99 | made_change = True 100 | if DEBUG_THIS: print(f'case=1 lhs.{lhsposattr}={getattr(lhs,lhsposattr)} lhs.{lhsxattr}={getattr(lhs,lhsxattr)} rhs.{rhsposattr}={getattr(rhs,rhsposattr)} rhs.{rhsxattr}={getattr(rhs,rhsxattr)} ') 101 | setattr(rhs, rhsxattr, getattr(lhs, lhsxattr) + lhs.OUTER_BUFFER) 102 | rhs.x2 = max(rhs.x2, rhs.x1 + rhs.width()) 103 | if DEBUG_THIS: print(f'case=1 lhs.{lhsposattr}={getattr(lhs,lhsposattr)} lhs.{lhsxattr}={getattr(lhs,lhsxattr)} rhs.{rhsposattr}={getattr(rhs,rhsposattr)} rhs.{rhsxattr}={getattr(rhs,rhsxattr)} ') 104 | startgroups = [s for s in chart.spans if rhs.start == s.start] 105 | if all(getattr(lhs, lhsposattr) == s.start - 1 and s.x1 > getattr(lhs, lhsxattr) + lhs.OUTER_BUFFER for s in startgroups): 106 | made_change = True 107 | maxwidth_x2 = max(s.x1 + s.width() for s in startgroups) 108 | for s in startgroups: 109 | if DEBUG_THIS: print(f'case=2 lhs.{lhsposattr}={getattr(lhs,lhsposattr)} lhs.{lhsxattr}={getattr(lhs,lhsxattr)} s.start={s.start} s.x1={s.x1} ') 110 | s.x1 = getattr(lhs, lhsxattr) + lhs.OUTER_BUFFER 111 | s.x2 = maxwidth_x2 112 | if DEBUG_THIS: print(f'case=2 lhs.{lhsposattr}={getattr(lhs,lhsposattr)} lhs.{lhsxattr}={getattr(lhs,lhsxattr)} s.start={s.start} s.x1={s.x1} ') 113 | endgroups = [s for s in chart.spans if rhs.end == s.end] 114 | if all(getattr(lhs, lhsposattr) == s.end - 1 and s.x1 + s.width() < s.x2 and s.x2 > getattr(lhs, lhsxattr) + lhs.OUTER_BUFFER for s in endgroups): 115 | made_change = True 116 | maxwidth_x2 = max(s.x1 + s.width() for s in endgroups) 117 | for s in endgroups: 118 | if DEBUG_THIS: print(f'case=3 lhs.{lhsposattr}={getattr(lhs,lhsposattr)} lhs.{lhsxattr}={getattr(lhs,lhsxattr)} s.end={s.end} s.x1={s.x1} s.width={s.width()} s.x2={s.x2} maxwidth={maxwidth_x2}') 119 | s.x2 = max(maxwidth_x2, getattr(lhs, lhsxattr) + lhs.OUTER_BUFFER) 120 | if DEBUG_THIS: print(f'case=3 lhs.{lhsposattr}={getattr(lhs,lhsposattr)} lhs.{lhsxattr}={getattr(lhs,lhsxattr)} s.end={s.end} s.x1={s.x1} s.width={s.width()} s.x2={s.x2} maxwidth={maxwidth_x2}') 121 | 122 | def chart_assign_xs_linprog(chart : model.Chart): 123 | base_heights = {} 124 | current_height = 0 125 | for actor in chart.actors: 126 | base_heights[actor.name] = current_height 127 | current_height += int(actor.slots) 128 | 129 | max_pos = max(s.end for s in chart.spans) + 1 130 | baseweights = [0] * max_pos 131 | 132 | # Aim to minimize the total width 133 | target = baseweights.copy() 134 | target[max_pos-1] = 1 135 | 136 | inequals = [] 137 | constants = [] 138 | # Ensure each X is separated by at least OUTER_BUFFER 139 | # x_0 + OUTER_BUFFER <= x_1 -> x_0 - x_1 <= -OUTER_BUFFER 140 | for idx in range(max_pos-1): 141 | ineq = baseweights.copy() 142 | ineq[idx] = 1 143 | ineq[idx+1] = -1 144 | inequals.append(ineq) 145 | outer_buffer = max(s.OUTER_BUFFER for s in chart.spans if s.start==idx or s.end==idx or s.eventpoint==idx) 146 | constants.append(-int(outer_buffer)) 147 | 148 | # Now add the constraints for the widths 149 | # x_0 + width >= x1 -> x_0 - x1 >= width -> x1 - x0 <= width 150 | for span in chart.spans: 151 | ineq = baseweights.copy() 152 | ineq[span.start] = -1 153 | ineq[span.end] = 1 154 | inequals.append(ineq) 155 | constants.append(int(span.width())) 156 | 157 | # Bounds is always (0, infinity) 158 | x_bounds = [(0, None)] * max_pos 159 | # Integer variable; decision variable must be an integer within bounds. 160 | integrality = [1] * max_pos 161 | 162 | for weights, const in zip(inequals, constants): 163 | lhs = ' + '.join([str(weight) + '*x_' + str(idx) for idx,weight in enumerate(weights) if weight != 0]) 164 | print(f'{lhs} <= {const}') 165 | 166 | import scipy.optimize 167 | result = scipy.optimize.linprog(target, A_ub=inequals, b_ub=constants, bounds=x_bounds, integrality=integrality) 168 | print(result) 169 | 170 | pos_to_ch = baseweights.copy() 171 | for idx, val in enumerate(result.x): 172 | pos_to_ch[idx] = units.Ch(int(val)) 173 | 174 | posxattrs = [['start', 'x1'], ['eventpoint', 'event_x'], ['end', 'x2']] 175 | for span in chart.spans: 176 | for posattr, xattr in posxattrs: 177 | if getattr(span, posattr) is not None: 178 | setattr(span, xattr, pos_to_ch[getattr(span, posattr)]) 179 | span.slot = units.Slot(base_heights[span.actor] + span.height) 180 | 181 | def chart_assign_xs_shittylinprog(chart : model.Chart): 182 | DEBUG_THIS = False 183 | base_heights = {} 184 | current_height = 0 185 | for actor in chart.actors: 186 | base_heights[actor.name] = current_height 187 | current_height += int(actor.slots) 188 | 189 | max_pos = max(s.end for s in chart.spans) + 1 190 | constraints = {} 191 | 192 | for idx in range(max_pos-1): 193 | outer_buffer = max(s.OUTER_BUFFER for s in chart.spans if s.start==idx or s.end==idx or s.eventpoint==idx) 194 | constraints[(idx, idx+1)] = int(outer_buffer) 195 | for span in chart.spans: 196 | constraints[(span.start, span.end)] = int(span.width()) 197 | 198 | xs = [0] * max_pos 199 | made_change = True 200 | while made_change: 201 | made_change = False 202 | for (start,end), v in constraints.items(): 203 | if xs[end] - xs[start] < v: 204 | made_change = True 205 | if DEBUG_THIS: print(f'xs[{start}]={xs[start]} xs[{end}]={xs[end]} after={v}') 206 | xs[end] = xs[start] + v 207 | 208 | pos_to_ch = [units.Ch(0)] * max_pos 209 | for idx, val in enumerate(xs): 210 | pos_to_ch[idx] = units.Ch(int(val)) 211 | 212 | posxattrs = [['start', 'x1'], ['eventpoint', 'event_x'], ['end', 'x2']] 213 | for span in chart.spans: 214 | for posattr, xattr in posxattrs: 215 | if getattr(span, posattr) is not None: 216 | setattr(span, xattr, pos_to_ch[getattr(span, posattr)]) 217 | span.slot = units.Slot(base_heights[span.actor] + span.height) 218 | 219 | 220 | chart_assign_xs = chart_assign_xs_shittylinprog 221 | 222 | class Renderable(abc.ABC): 223 | @abc.abstractmethod 224 | def x_min(self): pass 225 | @abc.abstractmethod 226 | def x_max(self): pass 227 | @abc.abstractmethod 228 | def y_min(self): pass 229 | @abc.abstractmethod 230 | def y_max(self): pass 231 | @abc.abstractmethod 232 | def render(self): pass 233 | @abc.abstractmethod 234 | def translate(self, x, y): pass 235 | 236 | @dataclasses.dataclass 237 | class Line(Renderable): 238 | x1 : Dimension 239 | y1 : Dimension 240 | x2 : Dimension 241 | y2 : Dimension 242 | attrs : Optional[dict[str, str]] 243 | 244 | def x_min(self): return min(self.x1, self.x2) 245 | def x_max(self): return max(self.x1, self.x2) 246 | def y_min(self): return min(self.y1, self.y2) 247 | def y_max(self): return max(self.y1, self.y2) 248 | def render(self): 249 | extra = ' '.join([f'{k.replace('_', '-')}="{v}"' for k,v in self.attrs.items()]) 250 | return f'' 251 | def translate(self, x : Dimension, y : Dimension): 252 | self.x1 += x 253 | self.x2 += x 254 | self.y1 += y 255 | self.y2 += y 256 | 257 | class XAlign(enum.StrEnum): 258 | START = "start" 259 | MIDDLE = "middle" 260 | END = "end" 261 | 262 | class YAlign(enum.StrEnum): 263 | TOP = "text-top" 264 | MIDDLE = "middle" 265 | BOTTOM = "baseline" 266 | 267 | @dataclasses.dataclass 268 | class Text(Renderable): 269 | x : Dimension 270 | y : Dimension 271 | xalign : XAlign 272 | yalign : YAlign 273 | text : str 274 | attrs : Optional[dict[str, str]] 275 | 276 | def x_min(self): 277 | match self.xalign: 278 | case XAlign.START: 279 | return self.x 280 | case XAlign.MIDDLE: 281 | return self.x - units.Ch(len(self.text)) / 2 282 | case XAlign.END: 283 | return self.x - units.Ch(len(self.text)) 284 | def x_max(self): 285 | match self.xalign: 286 | case XAlign.START: 287 | return self.x + units.Ch(len(self.text)) 288 | case XAlign.MIDDLE: 289 | return self.x + units.Ch(len(self.text)) / 2 290 | case XAlign.END: 291 | return self.x 292 | def y_min(self): 293 | match self.yalign: 294 | case YAlign.TOP: 295 | return self.y 296 | case YAlign.MIDDLE: 297 | return self.y - CH_HEIGHT_IN_PX/2 298 | case YAlign.BOTTOM: 299 | return self.y - CH_HEIGHT_IN_PX 300 | def y_max(self): 301 | match self.yalign: 302 | case YAlign.TOP: 303 | return self.y + CH_HEIGHT_IN_PX 304 | case YAlign.MIDDLE: 305 | return self.y + CH_HEIGHT_IN_PX/2 306 | case YAlign.BOTTOM: 307 | return self.y 308 | def render(self): 309 | extra = ' '.join([f'{k.replace('_', '-')}="{v}"' for k,v in self.attrs.items()]) 310 | return f'{self.text}' 311 | def translate(self, x : Dimension, y : Dimension): 312 | self.x += x 313 | self.y += y 314 | 315 | @dataclasses.dataclass 316 | class Circle(Renderable): 317 | x : Dimension 318 | y : Dimension 319 | r : Dimension 320 | attrs : Optional[dict[str, str]] 321 | 322 | # min/max for circles is hard because x can be in ch and y is in px 323 | # But our use of circles should never determine the boundaries, so 324 | # being wrong should be fine? 325 | def x_min(self): return self.x 326 | def x_max(self): return self.x 327 | def y_min(self): return self.y 328 | def y_max(self): return self.y 329 | def render(self): 330 | extra = ' '.join([f'{k.replace('_', '-')}="{v}"' for k,v in self.attrs.items()]) 331 | return f'' 332 | def translate(self, x : Dimension, y : Dimension): 333 | self.x += x 334 | self.y += y 335 | 336 | class SVG(object): 337 | def __init__(self): 338 | super() 339 | self._rendered = None 340 | self._contents = [] 341 | 342 | def x_min(self): return min([obj.x_min() for obj in self._contents]) 343 | def x_max(self): return max([obj.x_max() for obj in self._contents]) 344 | def y_min(self): return min([obj.y_min() for obj in self._contents]) 345 | def y_max(self): return max([obj.y_max() for obj in self._contents]) 346 | 347 | def line(self, x1 : Dimension, y1 : Dimension, x2 : Dimension, y2 : Dimension, **kwargs): 348 | kwargs.setdefault('stroke', 'black') 349 | obj = Line(x1, y1, x2, y2, kwargs) 350 | self._contents.append(obj) 351 | 352 | def text(self, x : Dimension, y : Dimension, xalign : XAlign, yalign : YAlign, text : str, **kwargs): 353 | obj = Text(x, y, xalign, yalign, text, kwargs) 354 | self._contents.append(obj) 355 | 356 | def circle(self, x : Dimension, y : Dimension, r : Dimension, **kwargs): 357 | obj = Circle(x, y, r, kwargs) 358 | self._contents.append(obj) 359 | 360 | def svg(self, x : Dimension, y : Dimension, svg : 'SVG'): 361 | svg.translate(x, y) 362 | self._contents.append(svg) 363 | 364 | def translate(self, x : Dimension, y : Dimension): 365 | for obj in self._contents: 366 | obj.translate(x, y) 367 | 368 | def render(self): 369 | if self._rendered: 370 | return self._rendered 371 | lines = [obj.render() for obj in self._contents] 372 | self._rendered = '\n'.join(lines) 373 | return self._rendered 374 | 375 | class RootSVG(SVG): 376 | def _svg_header(self, width : Dimension, height : Dimension) -> str: 377 | header = f'''''' 378 | header += textwrap.dedent(""" 379 | 380 | 397 | """) 398 | return header 399 | 400 | def _svg_footer(self) -> str: 401 | return '' 402 | 403 | def render(self): 404 | body = super().render() 405 | lines = [self._svg_header(self.x_max() + units.Ch(1), self.y_max() + PX_ACTORBAR_SEPARATION*2)] 406 | lines.append(body) 407 | lines.append(self._svg_footer()) 408 | return '\n'.join(lines) 409 | 410 | def actor_to_svg(actor : model.Actor) -> str: 411 | svg = SVG() 412 | svg.text(actor.x, actor.y, XAlign.END, YAlign.MIDDLE, actor.name) 413 | line_x = actor.x + OUTER_BUFFER 414 | top_y = actor.y + actor.height/2 415 | bottom_y = actor.y - actor.height/2 416 | svg.line(line_x, top_y, line_x, bottom_y) 417 | return svg 418 | 419 | def operation_to_svg(op : model.Operation) -> SVG: 420 | svg = SVG() 421 | mid_x = (op.x1 + op.x2)/2 422 | svg.text(mid_x, op.y, XAlign.MIDDLE, YAlign.BOTTOM, op.text) 423 | return svg 424 | 425 | def span_to_svg(span : model.Span) -> str: 426 | svg = SVG() 427 | svg.line(span.x1, span.y, span.x2, span.y) 428 | svg.line(span.x1, span.y-BARHEIGHT, span.x1, span.y+BARHEIGHT) 429 | svg.line(span.x2, span.y-BARHEIGHT, span.x2, span.y+BARHEIGHT) 430 | if span.event_x is not None: 431 | svg.circle(span.event_x, span.y, PX_EVENT_RADIUS) 432 | 433 | left_text, right_text = span.text 434 | y = span.y - PX_LINE_TEXT_SEPARATION 435 | if left_text and right_text: 436 | left_x = span.x1 + INNER_BUFFER 437 | svg.text(left_x, y, XAlign.START, YAlign.BOTTOM, left_text) 438 | right_x = span.x2 - INNER_BUFFER 439 | svg.text(right_x, y, XAlign.END, YAlign.BOTTOM, right_text) 440 | elif left_text or right_text: 441 | x = span.x1 + (span.x2 - span.x1)/2.0 442 | text = left_text or right_text 443 | svg.text(x, y, XAlign.MIDDLE, YAlign.BOTTOM, text) 444 | return svg 445 | 446 | def actors_to_slots_px(actors : list[model.Actor]) -> dict[units.Slot, units.Px]: 447 | slot = units.Slot(0) 448 | y = PX_CHAR_HEIGHT + PX_LINE_TEXT_SEPARATION 449 | px_of_slot = {} 450 | for actor in actors: 451 | for _ in range(int(actor.slots)): 452 | px_of_slot[slot] = y 453 | y += PX_SPAN_VERTICAL 454 | slot += 1 455 | y += PX_ACTORBAR_SEPARATION * 2 456 | return px_of_slot 457 | 458 | def chart_to_svg(chart : model.Chart) -> str: 459 | svg = RootSVG() 460 | 461 | px_of_slot = actors_to_slots_px(chart.actors) 462 | spans_of_actor = {} 463 | for span in chart.spans: 464 | span.y = px_of_slot[span.slot] 465 | spans_of_actor.setdefault(span.actor, []).append(span) 466 | 467 | actor_subregions = {} 468 | for span in chart.spans: 469 | match span: 470 | case model.Operation(): 471 | span_svg = operation_to_svg(span) 472 | case model.Span(): 473 | span_svg = span_to_svg(span) 474 | case _: 475 | assert False 476 | subregion = actor_subregions.setdefault(span.actor, SVG()) 477 | subregion.svg(units.Ch(0), units.Px(0), span_svg) 478 | 479 | show_actors = len(chart.actors) > 1 or chart.actors[0].name != "" 480 | max_actor_width = max([units.Ch(len(actor.name)) for actor in chart.actors]) 481 | for actor in chart.actors: 482 | subregion = actor_subregions[actor.name] 483 | actor.x = max_actor_width 484 | actor.height = subregion.y_max() - subregion.y_min() 485 | actor.y = subregion.y_min() + actor.height/2 486 | actor_svg = actor_to_svg(actor) 487 | if show_actors: 488 | svg.svg(units.Ch(1), 0, actor_svg) 489 | if constants.GUIDELINES: 490 | svg.line(units.Percent(0), actor.y-actor.height/2, units.Percent(100), actor.y-actor.height/2, stroke_dasharray="5") 491 | svg.line(units.Percent(0), actor.y+actor.height/2, units.Percent(100), actor.y+actor.height/2, stroke_dasharray="5") 492 | 493 | spans_x_offset = units.Ch(1) 494 | if show_actors: 495 | spans_x_offset += max_actor_width + OUTER_BUFFER * 2 496 | for spansvg in actor_subregions.values(): 497 | svg.svg(spans_x_offset, 0, spansvg) 498 | return svg.render() -------------------------------------------------------------------------------- /dbdiag/spans.py: -------------------------------------------------------------------------------- 1 | import bisect 2 | import dataclasses 3 | from typing import Optional 4 | from . import parser 5 | from . import constants 6 | from . import units 7 | from . import model 8 | from . import render 9 | from .units import * 10 | 11 | # Used to assign spans to a row, and keep track of how many rows need to exist 12 | # so that no span ever overlaps with another. 13 | # Each span acquire()s at its start, release()s at its end, and 14 | # max_token() gives the maximum number ever allocated at once. 15 | class TokenBucket(object): 16 | def __init__(self): 17 | self._tokens = [] 18 | self._max_token = -1 19 | 20 | def acquire(self) -> int: 21 | if self._tokens: 22 | token = self._tokens[0] 23 | self._tokens.pop(0) 24 | else: 25 | self._max_token += 1 26 | token = self._max_token 27 | return token 28 | 29 | def release(self, token : int) -> None: 30 | bisect.insort(self._tokens, token) 31 | 32 | def max_token(self) -> int: 33 | return self._max_token 34 | 35 | @dataclasses.dataclass 36 | class SpanStart(object): 37 | op : str 38 | start : int 39 | height : int 40 | eventpoint : Optional[int] = None 41 | 42 | def statements_to_spans(statements : list[parser.Statement]) -> model.Chart: 43 | inflight : dict[str, SpanStart] = {} 44 | actors_names : list[str] = [] 45 | actor_depth : dict[str, TokenBucket] = {} 46 | spans : list[model.Span] = [] 47 | 48 | for idx, group in enumerate(statements): 49 | for op in group: 50 | if op.actor not in actors_names: 51 | actors_names.append(op.actor) 52 | if op.actor not in actor_depth: 53 | actor_depth[op.actor] = TokenBucket() 54 | 55 | actorkey = (op.actor, op.key) 56 | if op.op == 'EVENT': 57 | inflight[actorkey].eventpoint = idx 58 | elif actorkey not in inflight: 59 | token = actor_depth[op.actor].acquire() 60 | inflight[actorkey] = SpanStart(op.op, idx, token) 61 | else: 62 | start = inflight[actorkey] 63 | del inflight[actorkey] 64 | x = idx 65 | spans.append(model.Span(op.actor, start.start, x, start.height, (start.op, op.op), start.eventpoint)) 66 | actor_depth[op.actor].release(start.height) 67 | 68 | if len(inflight) != 0: 69 | raise RuntimeError(f"Unfinished spans: {','.join('.'.join(t) for t in inflight.keys())}") 70 | 71 | actors = [model.Actor(name, actor_depth[name].max_token()+1) for name in actors_names] 72 | return model.Chart(actors, spans, []) 73 | 74 | #### Driver 75 | 76 | def to_span_svg(text_input, embed=None): 77 | if embed is True or embed is False: 78 | constants.EMBED = embed 79 | try: 80 | statements = parser.parse(text_input) 81 | except RuntimeError as e: 82 | return str(e) 83 | if not statements: 84 | return "" 85 | if constants.DEBUG: print(statements) 86 | chart = statements_to_spans(statements) 87 | if constants.DEBUG: print(chart) 88 | render.chart_assign_xs(chart) 89 | if constants.DEBUG: print(chart) 90 | svg = render.chart_to_svg(chart) 91 | if constants.DEBUG: print(svg) 92 | return svg -------------------------------------------------------------------------------- /dbdiag/units.py: -------------------------------------------------------------------------------- 1 | from . import constants 2 | from typing import TypeAlias 3 | 4 | class Dimension(object): 5 | def __init__(self, dist, unit): 6 | self._dist = dist 7 | self._unit = unit 8 | 9 | def convert(self, other): 10 | assert isinstance(other, Dimension) 11 | self._unit = other.unit 12 | self._dist *= other._dist 13 | 14 | def __str__(self): 15 | if constants.EMBED and self._unit == 'ch': 16 | return f'{self._dist * CH_WIDTH_IN_PX._dist}px' 17 | else: 18 | return f'{self._dist}{self._unit}' 19 | 20 | def __repr__(self): 21 | return f'Dimension({self._dist}, {self._unit})' 22 | 23 | def __int__(self): 24 | return int(self._dist) 25 | 26 | def __float__(self): 27 | return float(self._dist) 28 | 29 | def __add__(self, x : 'Dimension') -> 'Dimension': 30 | match x: 31 | case int(): 32 | return Dimension(self._dist + x, self._unit) 33 | case Dimension(): 34 | assert self._unit == x._unit 35 | return Dimension(self._dist + x._dist, self._unit) 36 | assert False 37 | 38 | def __radd__(self, x : 'Dimension') -> 'Dimension': 39 | return self + x 40 | 41 | def __iadd__(self, x : 'Dimension'): 42 | return self + x 43 | 44 | def __sub__(self, x : 'Dimension') -> 'Dimension': 45 | match x: 46 | case int(): 47 | return Dimension(self._dist - x, self._unit) 48 | case Dimension(): 49 | assert self._unit == x._unit 50 | return Dimension(self._dist - x._dist, self._unit) 51 | assert False 52 | 53 | def __rsub__(self, x : 'Dimension') -> 'Dimension': 54 | match x: 55 | case int(): 56 | return Dimension(x - self._dist, self._unit) 57 | case Dimension(): 58 | assert self._unit == x._unit 59 | return Dimension(x._dist - self._dist, self._unit) 60 | assert False 61 | 62 | def __isub__(self, x : 'Dimension'): 63 | return self - x 64 | 65 | def __mul__(self, x : 'Dimension') -> 'Dimension': 66 | match x: 67 | case int(): 68 | return Dimension(self._dist * x, self._unit) 69 | case Dimension(): 70 | assert self._unit == x._unit 71 | return Dimension(self._dist * x._dist, self._unit) 72 | assert False 73 | 74 | def __rmul__(self, x : 'Dimension') -> 'Dimension': 75 | return self * x 76 | 77 | def __truediv__(self, x : 'Dimension'): 78 | match x: 79 | case int(): 80 | return Dimension(self._dist / x, self._unit) 81 | case float(): 82 | return Dimension(self._dist / x, self._unit) 83 | case Dimension(): 84 | assert self._unit == x._unit 85 | return Dimension(self._dist / x._dist, self._unit) 86 | assert False 87 | 88 | def __floordiv__(self, x : 'Dimension'): 89 | match x: 90 | case int(): 91 | return Dimension(self._dist // x, self._unit) 92 | case Dimension(): 93 | assert self._unit == x._unit 94 | return Dimension(self._dist // x._dist, self._unit) 95 | assert False 96 | 97 | def __lt__(self, x : 'Dimension'): 98 | match x: 99 | case int(): 100 | return self._dist < x 101 | case Dimension(): 102 | if self._unit != x._unit and (self._unit == '%' or x._unit == '%'): 103 | return self._unit == '%' 104 | assert self._unit == x._unit 105 | return self._dist < x._dist 106 | assert False 107 | 108 | def __gt__(self, x : 'Dimension'): 109 | match x: 110 | case int(): 111 | return self._dist > x 112 | case Dimension(): 113 | if self._unit != x._unit and (self._unit == '%' or x._unit == '%'): 114 | return x._unit == '%' 115 | assert self._unit == x._unit 116 | return self._dist > x._dist 117 | assert False 118 | 119 | def __eq__(self, x : 'Dimension'): 120 | match x: 121 | case int(): 122 | return self._dist == x 123 | case Dimension(): 124 | assert self._unit == x._unit 125 | return self._dist == x._dist 126 | assert False 127 | 128 | def __neq__(self, x : 'Dimension'): 129 | match x: 130 | case int(): 131 | return self._dist != x 132 | case Dimension(): 133 | assert self._unit == x._unit 134 | return self._dist != x._dist 135 | assert False 136 | 137 | def __hash__(self): 138 | return hash((self._dist, self._unit)) 139 | 140 | @staticmethod 141 | def from_ch(ch : 'Ch') -> 'Dimension': 142 | assert not isinstance(ch, Dimension) 143 | return Dimension(ch, 'ch') 144 | 145 | @staticmethod 146 | def from_px(px : 'Px') -> 'Dimension': 147 | assert not isinstance(px, Dimension) 148 | return Dimension(px, 'px') 149 | 150 | @staticmethod 151 | def from_percent(p : 'Percent') -> 'Dimension': 152 | assert not isinstance(p, Dimension) 153 | return Dimension(p, '%') 154 | 155 | @staticmethod 156 | def from_slot(s : 'Slot') -> 'Dimension': 157 | assert not isinstance(s, Dimension) 158 | return Dimension(s, 'slot') 159 | 160 | class Ch(Dimension): 161 | unit = 'ch' 162 | def __init__(self, dist): 163 | super().__init__(dist, 'ch') 164 | 165 | class Px(Dimension): 166 | unit = 'px' 167 | def __init__(self, dist): 168 | super().__init__(dist, 'px') 169 | 170 | class Percent(Dimension): 171 | unit = '%' 172 | def __init__(self, dist): 173 | super().__init__(dist, '%') 174 | 175 | class Slot(Dimension): 176 | unit = 'slot' 177 | def __init__(self, dist): 178 | super().__init__(dist, 'slot') 179 | 180 | # OUTER_BUFFER | INNER_BUFFER INNER_INNER_BUFFER INNER_BUFFER | 181 | INNER_BUFFER : Dimension = Ch(1) 182 | INNER_INNER_BUFFER : Dimension = Ch(3) 183 | OUTER_BUFFER : Dimension = Ch(4) 184 | PX_CHAR_HEIGHT : Dimension = Px(15) 185 | PX_SPAN_VERTICAL : Dimension = Px(30) 186 | PX_LINE_TEXT_SEPARATION : Dimension = Px(6) 187 | BARHEIGHT : Dimension = Px(8) 188 | CH_ACTOR_SPAN_SEPARATION : Dimension = Ch(4) 189 | PX_ACTORBAR_SEPARATION : Dimension = Px(3) 190 | PX_EVENT_RADIUS : Dimension = Px(3) 191 | CH_WIDTH_IN_PX : Dimension = Px(7) 192 | CH_HEIGHT_IN_PX : Dimension = Px(12) 193 | -------------------------------------------------------------------------------- /docs/linearizability_1.2.a.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | A 15 | 16 | B 17 | 18 | 19 | 20 | 21 | E(y) 22 | 23 | 24 | 25 | E(x) 26 | 27 | 28 | 29 | D(x) 30 | 31 | 32 | 33 | D(y) 34 | 35 | 36 | 37 | E(z) 38 | -------------------------------------------------------------------------------- /docs/linearizability_1.2.b.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | A 15 | 16 | B 17 | 18 | 19 | 20 | 21 | E(x) 22 | 23 | 24 | 25 | E(y) 26 | 27 | 28 | 29 | D(y) 30 | -------------------------------------------------------------------------------- /docs/linearizability_1.2.c.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | A 15 | 16 | B 17 | 18 | 19 | 20 | 21 | D(x) 22 | 23 | 24 | 25 | E(x) 26 | -------------------------------------------------------------------------------- /docs/linearizability_1.2.d.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | A 15 | 16 | B 17 | 18 | C 19 | 20 | 21 | 22 | 23 | E(x) 24 | 25 | 26 | 27 | E(y) 28 | 29 | 30 | 31 | D(y) 32 | 33 | 34 | 35 | D(x) 36 | -------------------------------------------------------------------------------- /docs/ophistory_all.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 13 | 14 | A 15 | 16 | B 17 | 18 | 19 | 20 | 21 | Push(z) 22 | 23 | 24 | 25 | Push(a) 26 | 27 | 28 | 29 | 30 | Push(b) 31 | 32 | 33 | 34 | Push(c) 35 | 36 | 37 | 38 | PushAll([a,b,c]) 39 | 40 | 41 | 42 | 43 | Pop() 44 | Ok(b) 45 | -------------------------------------------------------------------------------- /docs/ophistory_all.txt: -------------------------------------------------------------------------------- 1 | A: PushAll([a,b,c]) ALL 2 | B: Push(z) 3 | B: Pop() BZ 4 | A: Push(a) A 5 | A: END A 6 | A: Push(b) B 7 | A: EVENT B 8 | B: EVENT BZ 9 | A: END B 10 | A: Push(c) C 11 | A: END C 12 | A: END ALL 13 | B: Ok(b) BZ 14 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "colorama" 5 | version = "0.4.6" 6 | description = "Cross-platform colored terminal text." 7 | optional = false 8 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 9 | files = [ 10 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 11 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 12 | ] 13 | 14 | [package.source] 15 | type = "legacy" 16 | url = "https://pypi.org/simple" 17 | reference = "pypi-public" 18 | 19 | [[package]] 20 | name = "exceptiongroup" 21 | version = "1.2.2" 22 | description = "Backport of PEP 654 (exception groups)" 23 | optional = false 24 | python-versions = ">=3.7" 25 | files = [ 26 | {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, 27 | {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, 28 | ] 29 | 30 | [package.extras] 31 | test = ["pytest (>=6)"] 32 | 33 | [package.source] 34 | type = "legacy" 35 | url = "https://pypi.org/simple" 36 | reference = "pypi-public" 37 | 38 | [[package]] 39 | name = "iniconfig" 40 | version = "2.0.0" 41 | description = "brain-dead simple config-ini parsing" 42 | optional = false 43 | python-versions = ">=3.7" 44 | files = [ 45 | {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, 46 | {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, 47 | ] 48 | 49 | [package.source] 50 | type = "legacy" 51 | url = "https://pypi.org/simple" 52 | reference = "pypi-public" 53 | 54 | [[package]] 55 | name = "packaging" 56 | version = "24.1" 57 | description = "Core utilities for Python packages" 58 | optional = false 59 | python-versions = ">=3.8" 60 | files = [ 61 | {file = "packaging-24.1-py3-none-any.whl", hash = "sha256:5b8f2217dbdbd2f7f384c41c628544e6d52f2d0f53c6d0c3ea61aa5d1d7ff124"}, 62 | {file = "packaging-24.1.tar.gz", hash = "sha256:026ed72c8ed3fcce5bf8950572258698927fd1dbda10a5e981cdf0ac37f4f002"}, 63 | ] 64 | 65 | [package.source] 66 | type = "legacy" 67 | url = "https://pypi.org/simple" 68 | reference = "pypi-public" 69 | 70 | [[package]] 71 | name = "pluggy" 72 | version = "1.5.0" 73 | description = "plugin and hook calling mechanisms for python" 74 | optional = false 75 | python-versions = ">=3.8" 76 | files = [ 77 | {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, 78 | {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, 79 | ] 80 | 81 | [package.extras] 82 | dev = ["pre-commit", "tox"] 83 | testing = ["pytest", "pytest-benchmark"] 84 | 85 | [package.source] 86 | type = "legacy" 87 | url = "https://pypi.org/simple" 88 | reference = "pypi-public" 89 | 90 | [[package]] 91 | name = "pytest" 92 | version = "8.3.3" 93 | description = "pytest: simple powerful testing with Python" 94 | optional = false 95 | python-versions = ">=3.8" 96 | files = [ 97 | {file = "pytest-8.3.3-py3-none-any.whl", hash = "sha256:a6853c7375b2663155079443d2e45de913a911a11d669df02a50814944db57b2"}, 98 | {file = "pytest-8.3.3.tar.gz", hash = "sha256:70b98107bd648308a7952b06e6ca9a50bc660be218d53c257cc1fc94fda10181"}, 99 | ] 100 | 101 | [package.dependencies] 102 | colorama = {version = "*", markers = "sys_platform == \"win32\""} 103 | exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} 104 | iniconfig = "*" 105 | packaging = "*" 106 | pluggy = ">=1.5,<2" 107 | tomli = {version = ">=1", markers = "python_version < \"3.11\""} 108 | 109 | [package.extras] 110 | dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] 111 | 112 | [package.source] 113 | type = "legacy" 114 | url = "https://pypi.org/simple" 115 | reference = "pypi-public" 116 | 117 | [[package]] 118 | name = "tomli" 119 | version = "2.0.2" 120 | description = "A lil' TOML parser" 121 | optional = false 122 | python-versions = ">=3.8" 123 | files = [ 124 | {file = "tomli-2.0.2-py3-none-any.whl", hash = "sha256:2ebe24485c53d303f690b0ec092806a085f07af5a5aa1464f3931eec36caaa38"}, 125 | {file = "tomli-2.0.2.tar.gz", hash = "sha256:d46d457a85337051c36524bc5349dd91b1877838e2979ac5ced3e710ed8a60ed"}, 126 | ] 127 | 128 | [package.source] 129 | type = "legacy" 130 | url = "https://pypi.org/simple" 131 | reference = "pypi-public" 132 | 133 | [metadata] 134 | lock-version = "2.0" 135 | python-versions = "^3.10" 136 | content-hash = "195dc287539b1cbe1bd9995fab1e7af74f4b707110e67e3e5c69abe7f2f021c5" 137 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "dbdiag" 3 | version = "0.0.0" 4 | description = "dbdiag" 5 | authors = ["Alex Miller "] 6 | license = "Apache-2.0" 7 | readme = "README.md" 8 | homepage = "https://github.com/thisismiller/dbdiag" 9 | repository = "https://github.com/thisismiller/dbdiag" 10 | documentation = "https://github.com/thisismiller/dbdiag/README.md" 11 | packages = [ 12 | { include = "dbdiag" }, 13 | ] 14 | classifiers = [ 15 | "Development Status :: 3 - Alpha", 16 | ] 17 | 18 | [tool.poetry.urls] 19 | Changelog = "https://github.com/thisismiller/dbdiag/releases" 20 | 21 | [tool.poetry.dependencies] 22 | python = "^3.10" 23 | 24 | [tool.poetry.dev-dependencies] 25 | pytest = ">=6.2.5" 26 | 27 | [[tool.poetry.source]] 28 | name = "pypi-public" 29 | url = "https://pypi.org/simple/" 30 | 31 | [tool.poetry.scripts] 32 | dbdiag = "dbdiag.cli:main" 33 | dbdiag-history = "dbdiag.cli:main_history" 34 | dbdiag-spans = "dbdiag.cli:main_spans" 35 | 36 | [tool.coverage.paths] 37 | source = ["src", "*/site-packages"] 38 | tests = ["tests", "*/tests"] 39 | 40 | [tool.coverage.run] 41 | branch = true 42 | source = ["src", "tests"] 43 | 44 | [tool.coverage.report] 45 | show_missing = true 46 | fail_under = 100 47 | 48 | [tool.isort] 49 | profile = "black" 50 | force_single_line = true 51 | lines_after_imports = 2 52 | 53 | [tool.mypy] 54 | strict = true 55 | warn_unreachable = true 56 | pretty = true 57 | show_column_numbers = true 58 | show_error_codes = true 59 | show_error_context = true 60 | 61 | [build-system] 62 | requires = ["poetry-core>=1.0.0"] 63 | build-backend = "poetry.core.masonry.api" 64 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Test suite for the dbdiag package.""" 2 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import textwrap 3 | import functools 4 | from dbdiag import parser 5 | 6 | def parser_test(fn): 7 | @functools.wraps(fn) 8 | def testcode(): 9 | text = textwrap.dedent(fn.__doc__) 10 | expected = fn() 11 | actual = parser.parse_statements(text) 12 | assert expected == actual 13 | return testcode 14 | 15 | def parser_test_raises(fn): 16 | @functools.wraps(fn) 17 | def testcode(): 18 | with pytest.raises(RuntimeError): 19 | text = textwrap.dedent(fn.__doc__) 20 | parser.parse_statements(text) 21 | return testcode 22 | 23 | @parser_test 24 | def test_one_span_with_end(): 25 | ''' 26 | S1: W(X) A 27 | S1: END A 28 | ''' 29 | return [ 30 | parser.Statement('S1', None, None, 'W(X)', 'A'), 31 | parser.Statement('S1', None, None, None, 'A') 32 | ] 33 | 34 | @parser_test 35 | def test_one_span(): 36 | ''' 37 | S1: W(X) A 38 | S1: 4 A 39 | ''' 40 | return [ 41 | parser.Statement('S1', None, None, 'W(X)', 'A'), 42 | parser.Statement('S1', None, None, '4', 'A') 43 | ] 44 | 45 | @parser_test 46 | def test_event(): 47 | ''' 48 | S1: W(X) A 49 | S1: EVENT A 50 | S1: 4 A 51 | ''' 52 | return [ 53 | parser.Statement('S1', None, None, 'W(X)', 'A'), 54 | parser.Statement('S1', None, None, 'EVENT', 'A'), 55 | parser.Statement('S1', None, None, '4', 'A') 56 | ] 57 | 58 | @parser_test 59 | def test_separator_dot(): 60 | ''' 61 | T1.R(X) A 62 | T1.END A 63 | T1.W(Y) B 64 | T1.END B 65 | ''' 66 | return [ 67 | parser.Statement('T1', None, None, 'R(X)', 'A'), 68 | parser.Statement('T1', None, None, None, 'A'), 69 | parser.Statement('T1', None, None, 'W(Y)', 'B'), 70 | parser.Statement('T1', None, None, None, 'B'), 71 | ] 72 | 73 | @parser_test 74 | def test_separator_dot(): 75 | ''' 76 | T1.R(X) 77 | T1.W(Y) 78 | ''' 79 | return [ 80 | parser.Statement('T1', None, None, 'R(X)', None), 81 | parser.Statement('T1', None, None, 'W(Y)', None), 82 | ] 83 | 84 | @parser_test_raises 85 | def test_separator_space(): 86 | ''' 87 | T1 R(X) 88 | T1 W(Y) 89 | ''' 90 | pass 91 | 92 | @parser_test 93 | def test_arrow(): 94 | ''' 95 | X->Y: R(X) A 96 | Y->Z: R(X) A.A 97 | Y<-Z: 4 A.A 98 | X<-Y: 4 A 99 | ''' 100 | return [ 101 | parser.Statement('X', '->', 'Y', 'R(X)', 'A'), 102 | parser.Statement('Y', '->', 'Z', 'R(X)', 'A.A'), 103 | parser.Statement('Y', '<-', 'Z', '4', 'A.A'), 104 | parser.Statement('X', '<-', 'Y', '4', 'A'), 105 | ] 106 | 107 | @parser_test 108 | def test_grouping(): 109 | ''' 110 | [ 111 | X: W(A) WA 112 | X: ok WA 113 | ] 114 | ''' 115 | return [ 116 | [parser.Statement('X', None, None, 'W(A)', 'WA'), 117 | parser.Statement('X', None, None, 'ok', 'WA')], 118 | ] 119 | 120 | def test_unsugar(): 121 | text = textwrap.dedent(''' 122 | T1.R(X) 123 | T1.W(Y) 124 | ''') 125 | ops = parser.parse_statements(text) 126 | ops = parser.unsugar_statements(ops) 127 | assert ops == [ 128 | [parser.Statement('T1', None, None, 'R(X)', '__0')], 129 | [parser.Statement('T1', None, None, None, '__0')], 130 | [parser.Statement('T1', None, None, 'W(Y)', '__1')], 131 | [parser.Statement('T1', None, None, None, '__1')], 132 | ] 133 | 134 | @parser_test 135 | def test_no_actors(): 136 | ''' 137 | : T1.W(A) 138 | : T1.W(B) 139 | ''' 140 | return [ 141 | parser.Statement('', None, None, 'T1.W(A)', None), 142 | parser.Statement('', None, None, 'T1.W(B)', None), 143 | ] -------------------------------------------------------------------------------- /tests/test_spans.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import textwrap 3 | import functools 4 | from dbdiag import parser, model, spans, units 5 | 6 | def spans_test(fn): 7 | @functools.wraps(fn) 8 | def testcode(): 9 | text = textwrap.dedent(fn.__doc__) 10 | expected = fn() 11 | actual = parser.parse(text) 12 | actual = spans.statements_to_spans(actual) 13 | assert expected == actual 14 | return testcode 15 | 16 | def spans_test_raises(fn): 17 | @functools.wraps(fn) 18 | def testcode(): 19 | with pytest.raises(RuntimeError): 20 | text = textwrap.dedent(fn.__doc__) 21 | ast = parser.parse(text) 22 | spans.statements_to_spans(ast) 23 | return testcode 24 | 25 | @spans_test 26 | def test_spans(): 27 | ''' 28 | A: W(A) A 29 | A: ok A 30 | ''' 31 | return model.Chart([model.Actor('A', units.Slot(1))], [ 32 | model.Span('A', 0, 1, 0, ('W(A)', 'ok'), None) 33 | ], []) 34 | 35 | @spans_test 36 | def test_short_spans(): 37 | ''' 38 | A: W(X) 39 | B: R(X) 40 | ''' 41 | return model.Chart([model.Actor('A', units.Slot(1)), model.Actor('B', units.Slot(1))], [ 42 | model.Span('A', 0, 1, 0, ('W(X)', None), None), 43 | model.Span('B', 2, 3, 0, ('R(X)', None), None) 44 | ], []) 45 | 46 | @spans_test 47 | def test_groups(): 48 | ''' 49 | [ 50 | A: W(A) A 51 | B: W(B) B 52 | ] 53 | A: ok A 54 | B: ok B 55 | ''' 56 | return model.Chart([model.Actor('A', units.Slot(1)), model.Actor('B', units.Slot(1))], [ 57 | model.Span('A', 0, 1, 0, ('W(A)', 'ok'), None), 58 | model.Span('B', 0, 2, 0, ('W(B)', 'ok'), None) 59 | ], []) --------------------------------------------------------------------------------