span-table 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[codz]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # PyInstaller
30
+ # Usually these files are written by a python script from a template
31
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
32
+ *.manifest
33
+ *.spec
34
+
35
+ # Installer logs
36
+ pip-log.txt
37
+ pip-delete-this-directory.txt
38
+
39
+ # Unit test / coverage reports
40
+ htmlcov/
41
+ .tox/
42
+ .nox/
43
+ .coverage
44
+ .coverage.*
45
+ .cache
46
+ nosetests.xml
47
+ coverage.xml
48
+ *.cover
49
+ *.py.cover
50
+ .hypothesis/
51
+ .pytest_cache/
52
+ cover/
53
+
54
+ # Translations
55
+ *.mo
56
+ *.pot
57
+
58
+ # Django stuff:
59
+ *.log
60
+ local_settings.py
61
+ db.sqlite3
62
+ db.sqlite3-journal
63
+
64
+ # Flask stuff:
65
+ instance/
66
+ .webassets-cache
67
+
68
+ # Scrapy stuff:
69
+ .scrapy
70
+
71
+ # Sphinx documentation
72
+ docs/_build/
73
+
74
+ # PyBuilder
75
+ .pybuilder/
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ # For a library or package, you might want to ignore these files since the code is
87
+ # intended to run in multiple environments; otherwise, check them in:
88
+ # .python-version
89
+
90
+ # pipenv
91
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
+ # install all needed dependencies.
95
+ # Pipfile.lock
96
+
97
+ # UV
98
+ # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
99
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
100
+ # commonly ignored for libraries.
101
+ # uv.lock
102
+
103
+ # poetry
104
+ # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
105
+ # This is especially recommended for binary packages to ensure reproducibility, and is more
106
+ # commonly ignored for libraries.
107
+ # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
108
+ # poetry.lock
109
+ # poetry.toml
110
+
111
+ # pdm
112
+ # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
113
+ # pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
114
+ # https://pdm-project.org/en/latest/usage/project/#working-with-version-control
115
+ # pdm.lock
116
+ # pdm.toml
117
+ .pdm-python
118
+ .pdm-build/
119
+
120
+ # pixi
121
+ # Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
122
+ # pixi.lock
123
+ # Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
124
+ # in the .venv directory. It is recommended not to include this directory in version control.
125
+ .pixi
126
+
127
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
128
+ __pypackages__/
129
+
130
+ # Celery stuff
131
+ celerybeat-schedule
132
+ celerybeat.pid
133
+
134
+ # Redis
135
+ *.rdb
136
+ *.aof
137
+ *.pid
138
+
139
+ # RabbitMQ
140
+ mnesia/
141
+ rabbitmq/
142
+ rabbitmq-data/
143
+
144
+ # ActiveMQ
145
+ activemq-data/
146
+
147
+ # SageMath parsed files
148
+ *.sage.py
149
+
150
+ # Environments
151
+ .env
152
+ .envrc
153
+ .venv
154
+ env/
155
+ venv/
156
+ ENV/
157
+ env.bak/
158
+ venv.bak/
159
+
160
+ # Spyder project settings
161
+ .spyderproject
162
+ .spyproject
163
+
164
+ # Rope project settings
165
+ .ropeproject
166
+
167
+ # mkdocs documentation
168
+ /site
169
+
170
+ # mypy
171
+ .mypy_cache/
172
+ .dmypy.json
173
+ dmypy.json
174
+
175
+ # Pyre type checker
176
+ .pyre/
177
+
178
+ # pytype static type analyzer
179
+ .pytype/
180
+
181
+ # Cython debug symbols
182
+ cython_debug/
183
+
184
+ # PyCharm
185
+ # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
186
+ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
187
+ # and can be added to the global gitignore or merged into this file. For a more nuclear
188
+ # option (not recommended) you can uncomment the following to ignore the entire idea folder.
189
+ # .idea/
190
+
191
+ # Abstra
192
+ # Abstra is an AI-powered process automation framework.
193
+ # Ignore directories containing user credentials, local state, and settings.
194
+ # Learn more at https://abstra.io/docs
195
+ .abstra/
196
+
197
+ # Visual Studio Code
198
+ # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
199
+ # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
200
+ # and can be added to the global gitignore or merged into this file. However, if you prefer,
201
+ # you could uncomment the following to ignore the entire vscode folder
202
+ # .vscode/
203
+ # Temporary file for partial code execution
204
+ tempCodeRunnerFile.py
205
+
206
+ # Ruff stuff:
207
+ .ruff_cache/
208
+
209
+ # PyPI configuration file
210
+ .pypirc
211
+
212
+ # Marimo
213
+ marimo/_static/
214
+ marimo/_lsp/
215
+ __marimo__/
216
+
217
+ # Streamlit
218
+ .streamlit/secrets.toml
@@ -0,0 +1 @@
1
+ 3.14
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Soami Charan
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.4
2
+ Name: span-table
3
+ Version: 0.1.0
4
+ Summary: span-table is a lightweight Python helper for rendering ASCII tables with merged cells using Rich-style markup.
5
+ Project-URL: Repository, https://github.com/soamicharan/span-table
6
+ Project-URL: Homepage, https://github.com/soamicharan/span-table
7
+ Project-URL: Issues, https://github.com/soamicharan/span-table/issues
8
+ Author-email: Soami Charan <charansoami@gmail.com>
9
+ Maintainer-email: Soami Charan <charansoami@gmail.com>
10
+ License: MIT
11
+ License-File: LICENSE
12
+ Keywords: ascii,merged cells,rich,span,table
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: rich>=15.0.0
20
+ Description-Content-Type: text/markdown
21
+
22
+ # span-table
23
+
24
+ `span-table` is a lightweight Python helper for rendering ASCII tables with merged cells using Rich-style markup.
25
+
26
+ ## Features
27
+
28
+ - Render table layouts with custom merged spans
29
+ - Support multi-line cell content
30
+ - Uses `rich` box styles for flexible borders
31
+ - Compatible with Python `>=3.14`
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install span-table
37
+ ```
38
+
39
+ ## Quick Start
40
+
41
+ ```python
42
+ from span_table import span_table
43
+ from rich import print
44
+
45
+ table = [
46
+ ["Header 1", "Header 2", "Header3", "Header 4"],
47
+ ["row 1", "column 2", "column 3", "column 4"],
48
+ ["row 2", "Cells span columns.", "", ""],
49
+ ["row 3", "Cells\nspan rows.", "- Cells\n- contain\n- blocks", ""],
50
+ ["row 4", "", "", ""]
51
+ ]
52
+
53
+ # Spans are defined as ((start_row, start_col), (end_row, end_col)) with zero-based indexes
54
+ my_spans = [
55
+ ((2, 1), (2, 3)),
56
+ ((3, 1), (4, 1)),
57
+ ((3, 2), (4, 3)),
58
+ ]
59
+
60
+ print(span_table(table, my_spans))
61
+ ```
62
+
63
+ This renders:
64
+
65
+ ```text
66
+ ┌──────────┬────────────┬──────────┬──────────┐
67
+ │ Header 1 │ Header 2 │ Header3 │ Header 4 │
68
+ ├──────────┼────────────┼──────────┼──────────┤
69
+ │ row 1 │ column 2 │ column 3 │ column 4 │
70
+ ├──────────┼────────────┴──────────┴──────────┤
71
+ │ row 2 │ Cells span columns. │
72
+ ├──────────┼────────────┬─────────────────────┤
73
+ │ row 3 │ Cells │ - Cells │
74
+ ├──────────┤ span rows. │ - contain │
75
+ │ row 4 │ │ - blocks │
76
+ └──────────┴────────────┴─────────────────────┘
77
+ ```
78
+
79
+ ## Advanced Usage
80
+
81
+ ```python
82
+ from span_table import span_table
83
+ from rich import print
84
+
85
+ table = [
86
+ ["Time", "[on red]Height[/]", "", "[on green]Weight[/]", ""],
87
+ ["", "Total", "Change", "Total", "Change"],
88
+ ["15:30", "1", "2", "3", "4"]
89
+ ]
90
+
91
+ spans = [
92
+ ((0, 0), (1, 0)),
93
+ ((0, 1), (0, 2)),
94
+ ((0, 3), (0, 4))
95
+ ]
96
+
97
+ print(span_table(table, spans))
98
+ ```
99
+
100
+ This renders:
101
+
102
+ ```text
103
+ ┌───────┬────────────────┬────────────────┐
104
+ │ Time │<red bg> Height │<Green BG>Weight│
105
+ │ ├───────┬────────┼───────┬────────┤
106
+ │ │ Total │ Change │ Total │ Change │
107
+ ├───────┼───────┼────────┼───────┼────────┤
108
+ │ 15:30 │ 1 │ 2 │ 3 │ 4 │
109
+ └───────┴───────┴────────┴───────┴────────┘
110
+ ```
111
+
112
+ ## API
113
+
114
+ ### `span_table(data, spans, box=rich_box.SQUARE) -> str`
115
+
116
+ Render a text table with optional merged cells.
117
+
118
+ - `data`: `list[list[str]]` — table rows and columns
119
+ - `spans`: `list[tuple[tuple[int, int], tuple[int, int]]]` — list of merge regions
120
+ - `box`: `rich.box.Box` — optional Rich box style such as `box.SQUARE`
121
+
122
+ ### Span rules
123
+
124
+ - Each span is a rectangular region defined by a start and end coordinate
125
+ - Coordinates are zero-based: `(row_index, column_index)`
126
+ - Spans can cover a single cell or multiple cells
127
+ - Spans must be within the table bounds
128
+
129
+ ## Rich markup support
130
+
131
+ Cell values may include Rich markup tags such as `[bold]`, `[italic]`, or color tags but does not support emoji yet. The library preserves markup while measuring text layout.
132
+
133
+ ## Notes
134
+
135
+ - Ensure each row has the same number of columns
136
+ - Non-merged cells may be omitted from `spans`
137
+ - The library automatically fills any cells not included in spans as standard individual cells
@@ -0,0 +1,116 @@
1
+ # span-table
2
+
3
+ `span-table` is a lightweight Python helper for rendering ASCII tables with merged cells using Rich-style markup.
4
+
5
+ ## Features
6
+
7
+ - Render table layouts with custom merged spans
8
+ - Support multi-line cell content
9
+ - Uses `rich` box styles for flexible borders
10
+ - Compatible with Python `>=3.14`
11
+
12
+ ## Installation
13
+
14
+ ```bash
15
+ pip install span-table
16
+ ```
17
+
18
+ ## Quick Start
19
+
20
+ ```python
21
+ from span_table import span_table
22
+ from rich import print
23
+
24
+ table = [
25
+ ["Header 1", "Header 2", "Header3", "Header 4"],
26
+ ["row 1", "column 2", "column 3", "column 4"],
27
+ ["row 2", "Cells span columns.", "", ""],
28
+ ["row 3", "Cells\nspan rows.", "- Cells\n- contain\n- blocks", ""],
29
+ ["row 4", "", "", ""]
30
+ ]
31
+
32
+ # Spans are defined as ((start_row, start_col), (end_row, end_col)) with zero-based indexes
33
+ my_spans = [
34
+ ((2, 1), (2, 3)),
35
+ ((3, 1), (4, 1)),
36
+ ((3, 2), (4, 3)),
37
+ ]
38
+
39
+ print(span_table(table, my_spans))
40
+ ```
41
+
42
+ This renders:
43
+
44
+ ```text
45
+ ┌──────────┬────────────┬──────────┬──────────┐
46
+ │ Header 1 │ Header 2 │ Header3 │ Header 4 │
47
+ ├──────────┼────────────┼──────────┼──────────┤
48
+ │ row 1 │ column 2 │ column 3 │ column 4 │
49
+ ├──────────┼────────────┴──────────┴──────────┤
50
+ │ row 2 │ Cells span columns. │
51
+ ├──────────┼────────────┬─────────────────────┤
52
+ │ row 3 │ Cells │ - Cells │
53
+ ├──────────┤ span rows. │ - contain │
54
+ │ row 4 │ │ - blocks │
55
+ └──────────┴────────────┴─────────────────────┘
56
+ ```
57
+
58
+ ## Advanced Usage
59
+
60
+ ```python
61
+ from span_table import span_table
62
+ from rich import print
63
+
64
+ table = [
65
+ ["Time", "[on red]Height[/]", "", "[on green]Weight[/]", ""],
66
+ ["", "Total", "Change", "Total", "Change"],
67
+ ["15:30", "1", "2", "3", "4"]
68
+ ]
69
+
70
+ spans = [
71
+ ((0, 0), (1, 0)),
72
+ ((0, 1), (0, 2)),
73
+ ((0, 3), (0, 4))
74
+ ]
75
+
76
+ print(span_table(table, spans))
77
+ ```
78
+
79
+ This renders:
80
+
81
+ ```text
82
+ ┌───────┬────────────────┬────────────────┐
83
+ │ Time │<red bg> Height │<Green BG>Weight│
84
+ │ ├───────┬────────┼───────┬────────┤
85
+ │ │ Total │ Change │ Total │ Change │
86
+ ├───────┼───────┼────────┼───────┼────────┤
87
+ │ 15:30 │ 1 │ 2 │ 3 │ 4 │
88
+ └───────┴───────┴────────┴───────┴────────┘
89
+ ```
90
+
91
+ ## API
92
+
93
+ ### `span_table(data, spans, box=rich_box.SQUARE) -> str`
94
+
95
+ Render a text table with optional merged cells.
96
+
97
+ - `data`: `list[list[str]]` — table rows and columns
98
+ - `spans`: `list[tuple[tuple[int, int], tuple[int, int]]]` — list of merge regions
99
+ - `box`: `rich.box.Box` — optional Rich box style such as `box.SQUARE`
100
+
101
+ ### Span rules
102
+
103
+ - Each span is a rectangular region defined by a start and end coordinate
104
+ - Coordinates are zero-based: `(row_index, column_index)`
105
+ - Spans can cover a single cell or multiple cells
106
+ - Spans must be within the table bounds
107
+
108
+ ## Rich markup support
109
+
110
+ Cell values may include Rich markup tags such as `[bold]`, `[italic]`, or color tags but does not support emoji yet. The library preserves markup while measuring text layout.
111
+
112
+ ## Notes
113
+
114
+ - Ensure each row has the same number of columns
115
+ - Non-merged cells may be omitted from `spans`
116
+ - The library automatically fills any cells not included in spans as standard individual cells
@@ -0,0 +1,6 @@
1
+ def main():
2
+ print("Hello from span-table!")
3
+
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,33 @@
1
+ [project]
2
+ name = "span-table"
3
+ version = "0.1.0"
4
+ description = "span-table is a lightweight Python helper for rendering ASCII tables with merged cells using Rich-style markup."
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ dependencies = [
8
+ "rich>=15.0.0",
9
+ ]
10
+ license = { text = "MIT" }
11
+ license-files = ["LICENSE"]
12
+ authors = [{ name = "Soami Charan", email = "charansoami@gmail.com" }]
13
+ maintainers = [{ name = "Soami Charan", email = "charansoami@gmail.com" }]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3 :: Only",
17
+ "Programming Language :: Python :: 3.9",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ ]
21
+ keywords = ["table", "ascii", "rich", "span", "merged cells"]
22
+
23
+ [project.urls]
24
+ Repository = "https://github.com/soamicharan/span-table"
25
+ Homepage = "https://github.com/soamicharan/span-table"
26
+ Issues = "https://github.com/soamicharan/span-table/issues"
27
+
28
+ [build-system]
29
+ requires = ["hatchling"]
30
+ build-backend = "hatchling.build"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/span_table"]
@@ -0,0 +1,3 @@
1
+ from table import span_table
2
+
3
+ __all__ = ["span_table"]
@@ -0,0 +1,265 @@
1
+ from src.span_table.span import (
2
+ get_span_column_count,
3
+ get_span_row_count,
4
+ get_span_char_height,
5
+ get_span_char_width
6
+ )
7
+ from typing import Literal
8
+ from dataclasses import dataclass
9
+ from rich.box import Box
10
+ from src.span_table.types import SpanType, TableType
11
+
12
+ @dataclass
13
+ class Cell:
14
+ text: str
15
+ row: int
16
+ column: int
17
+ row_count: int
18
+ column_count: int
19
+
20
+ def __lt__(self, other):
21
+ return [self.row, self.column] < [other.row, other.column]
22
+
23
+ @property
24
+ def left_sections(self):
25
+ lines = self.text.split('\n')
26
+ sections = 0
27
+
28
+ for i in range(len(lines)):
29
+ if lines[i].startswith('+'):
30
+ sections += 1
31
+ sections -= 1
32
+
33
+ return sections
34
+
35
+ @property
36
+ def right_sections(self):
37
+ lines = self.text.split('\n')
38
+ sections = 0
39
+ for i in range(len(lines)):
40
+ if lines[i].endswith('+'):
41
+ sections += 1
42
+ return sections - 1
43
+
44
+ @property
45
+ def top_sections(self):
46
+ top_line = self.text.split('\n')[0]
47
+ sections = len(top_line.split('+')) - 2
48
+
49
+ return sections
50
+
51
+ @property
52
+ def bottom_sections(self):
53
+ bottom_line = self.text.split('\n')[-1]
54
+ sections = len(bottom_line.split('+')) - 2
55
+
56
+ return sections
57
+
58
+ def resolve_border(
59
+ box: Box,
60
+ row: int,
61
+ col: int,
62
+ total_row: int,
63
+ total_col: int,
64
+ span: SpanType,
65
+ corner: Literal['top_left', 'top_right', 'bottom_left', 'bottom_right']
66
+ ):
67
+ if corner == "top_left":
68
+ if row == 0 and col == 0:
69
+ return box.top_left
70
+ elif 0 < row and col == 0:
71
+ return box.row_left
72
+ elif row == 0 and 0 < col:
73
+ return box.top_divider
74
+ else:
75
+ return box.row_cross
76
+ elif corner == "top_right":
77
+ if len(span) > 1:
78
+ row, col = span[0][0], span[-1][1]
79
+ if row == 0 and col == (total_col - 1):
80
+ return box.top_right
81
+ elif row > 0 and col == (total_col - 1):
82
+ return box.row_right
83
+ elif row == 0 and col < (total_col - 1):
84
+ return box.top_divider
85
+ else:
86
+ return box.row_cross
87
+ elif corner == "bottom_left":
88
+ if len(span) > 1:
89
+ row, col = span[-1][0], span[0][1]
90
+ if row == (total_row - 1) and col == 0:
91
+ return box.bottom_left
92
+ elif row == (total_row - 1) and col > 0:
93
+ return box.bottom_divider
94
+ elif row < (total_row - 1) and col == 0:
95
+ return box.row_left
96
+ else:
97
+ return box.row_cross
98
+ elif corner == "bottom_right":
99
+ if len(span) > 1:
100
+ row, col = span[-1]
101
+ if row == (total_row - 1) and col == (total_col - 1):
102
+ return box.bottom_right
103
+ elif row == (total_row - 1) and col < (total_col - 1):
104
+ return box.bottom_divider
105
+ elif row < (total_row - 1) and col == (total_col - 1):
106
+ return box.row_right
107
+ else:
108
+ return box.row_cross
109
+
110
+
111
+ def make_cell(table: TableType, span: SpanType, widths: list[int], heights: list[int], box: Box) -> Cell:
112
+ width = get_span_char_width(span, widths)
113
+ height = get_span_char_height(span, heights)
114
+ text_row = span[0][0]
115
+ text_column = span[0][1]
116
+ text = table[text_row][text_column]
117
+ total_cols = len(widths)
118
+ total_rows = len(heights)
119
+
120
+ lines = text.plain.split("\n")
121
+ text.pad_width(width)
122
+
123
+ height_difference = height - len(lines)
124
+ text.append_empty_lines(height_difference, width)
125
+ output = [
126
+ ''.join([
127
+ resolve_border(box, text_row, text_column, total_rows, total_cols, span, 'top_left'),
128
+ (width * box.row_horizontal),
129
+ resolve_border(box, text_row, text_column, total_rows, total_cols, span, 'top_right')
130
+ ])
131
+ ]
132
+
133
+ for line in text.markup.split('\n'):
134
+ output.append(box.mid_vertical + line + box.mid_vertical)
135
+
136
+ symbol = box.row_horizontal
137
+
138
+ output.append(
139
+ ''.join([
140
+ resolve_border(box, text_row, text_column, total_rows, total_cols, span, 'bottom_left'),
141
+ width * symbol,
142
+ resolve_border(box, text_row, text_column, total_rows, total_cols, span, 'bottom_right')
143
+ ])
144
+ )
145
+
146
+ text = "\n".join(output)
147
+ row_count = get_span_row_count(span)
148
+ column_count = get_span_column_count(span)
149
+ return Cell(text=text, row=text_row, column=text_column, row_count=row_count, column_count=column_count)
150
+
151
+
152
+ def merge_cells(cell1: Cell, cell2: Cell, direction: Literal["RIGHT", "TOP", "BOTTOM", "LEFT"], box: Box):
153
+ cell1_lines = cell1.text.split("\n")
154
+ cell2_lines = cell2.text.split("\n")
155
+ if direction == "RIGHT":
156
+ for i in range(len(cell1_lines)):
157
+ sub = cell1_lines[i][-1]
158
+ if cell1_lines[i][-1] == box.mid_vertical and cell2_lines[i][0] == box.row_cross:
159
+ sub = box.row_left
160
+ elif cell1_lines[i][-1] == box.row_cross and cell2_lines[i][0] == box.mid_vertical:
161
+ sub = box.row_right
162
+ elif cell1_lines[i][-1] == box.row_cross and (cell2_lines[i][0] == box.top_left or cell2_lines[i][0] == box.top_divider):
163
+ sub = box.top_divider
164
+
165
+ cell1_lines[i] = cell1_lines[i][:-1] + sub + cell2_lines[i][1:]
166
+ cell1.text = "\n".join(cell1_lines)
167
+ cell1.column_count += cell2.column_count
168
+
169
+ elif direction == "TOP":
170
+ if cell1_lines[0].count('+') > cell2_lines[-1].count('+'):
171
+ cell2_lines.pop(-1)
172
+ else:
173
+ cell1_lines.pop(0)
174
+ cell2_lines.extend(cell1_lines)
175
+ cell1.text = "\n".join(cell2_lines)
176
+ cell1.row_count += cell2.row_count
177
+ cell1.row = cell2.row
178
+ cell1.column = cell2.column
179
+
180
+ elif direction == "BOTTOM":
181
+ merged_cells = ""
182
+ for top_char, bottom_char in zip(cell1_lines[-1], cell2_lines[0]):
183
+ if top_char == box.row_cross and bottom_char == box.row_horizontal:
184
+ merged_cells += box.bottom_divider
185
+ elif top_char == box.row_horizontal and bottom_char == box.row_cross:
186
+ merged_cells += box.top_divider
187
+ else:
188
+ merged_cells += top_char
189
+ cell1_lines[-1] = merged_cells
190
+ cell1_lines.extend(cell2_lines[1:])
191
+ cell1.text = "\n".join(cell1_lines)
192
+ cell1.row_count += cell2.row_count
193
+
194
+ elif direction == "LEFT":
195
+ for i in range(len(cell1_lines)):
196
+ sub = cell1_lines[i]
197
+ if cell1_lines[i][0] == box.mid_vertical and cell2_lines[i][-1] == box.row_cross:
198
+ sub = box.row_right + sub[1:]
199
+ elif cell1_lines[i][0] == box.row_cross and cell2_lines[i][-1] == box.mid_vertical:
200
+ sub = box.row_left + sub[1:]
201
+
202
+ cell1_lines[i] = cell2_lines[i][0:-1] + sub
203
+ cell1.text = "\n".join(cell1_lines)
204
+ cell1.column_count += cell2.column_count
205
+ cell1.row = cell2.row
206
+ cell1.column = cell2.column
207
+
208
+ def merge_all_cells(cells: list[Cell], box: Box) -> str:
209
+ current = 0
210
+ while len(cells) > 1:
211
+ count = 0
212
+ while count < len(cells):
213
+ cell1 = cells[current]
214
+ cell2 = cells[count]
215
+ merge_direction = get_merge_direction(cell1, cell2)
216
+ if not merge_direction == "NONE":
217
+ merge_cells(cell1, cell2, merge_direction, box)
218
+ if current > count:
219
+ current -= 1
220
+
221
+ cells.pop(count)
222
+ else:
223
+ count += 1
224
+
225
+ current += 1
226
+ if current >= len(cells):
227
+ current = 0
228
+
229
+ return cells[0].text
230
+
231
+ def get_merge_direction(cell1: Cell, cell2: Cell) -> Literal["RIGHT", "TOP", "BOTTOM", "LEFT", "NONE"]:
232
+ cell1_left = cell1.column
233
+ cell1_right = cell1.column + cell1.column_count
234
+ cell1_top = cell1.row
235
+ cell1_bottom = cell1.row + cell1.row_count
236
+ cell2_left = cell2.column
237
+ cell2_right = cell2.column + cell2.column_count
238
+ cell2_top = cell2.row
239
+ cell2_bottom = cell2.row + cell2.row_count
240
+
241
+ if (cell1_right == cell2_left and cell1_top == cell2_top and
242
+ cell1_bottom == cell2_bottom and
243
+ cell1.right_sections >= cell2.left_sections):
244
+ return "RIGHT"
245
+
246
+ elif (cell1_left == cell2_left and cell1_right == cell2_right and
247
+ cell1_top == cell2_bottom and
248
+ cell1.top_sections >= cell2.bottom_sections):
249
+ return "TOP"
250
+
251
+ elif (cell1_left == cell2_left and
252
+ cell1_right == cell2_right and
253
+ cell1_bottom == cell2_top and
254
+ cell1.bottom_sections >= cell2.top_sections):
255
+ return "BOTTOM"
256
+
257
+ elif (cell1_left == cell2_right and
258
+ cell1_top == cell2_top and
259
+ cell1_bottom == cell2_bottom and
260
+ cell1.left_sections >= cell2.right_sections):
261
+ return "LEFT"
262
+
263
+ else:
264
+ return "NONE"
265
+
@@ -0,0 +1,62 @@
1
+ from dataclasses import dataclass
2
+ from rich.markup import Tag, _parse
3
+
4
+
5
+ @dataclass
6
+ class StyleToken:
7
+ tag: Tag
8
+
9
+ @dataclass
10
+ class TextToken:
11
+ text: str
12
+
13
+ def pad_width(self, width: int):
14
+ pad = max(0, width - len(self.text))
15
+ if pad:
16
+ self.text += ' ' * pad
17
+
18
+ class MarkupText:
19
+ def __init__(self, markup: str):
20
+ self._tokens: list[StyleToken | TextToken] = []
21
+ self._parse(markup)
22
+
23
+ def _parse(self, markup: str):
24
+ for _, text, tag in _parse(markup):
25
+ if text is not None:
26
+ for line in text.split('\n'):
27
+ if line == '':
28
+ continue
29
+ self._tokens.append(TextToken(f" {line} "))
30
+ else:
31
+ self._tokens.append(StyleToken(tag))
32
+
33
+ @property
34
+ def plain(self) -> str:
35
+ return "\n".join(l.text for l in self.text_tokens)
36
+
37
+ @property
38
+ def markup(self) -> str:
39
+ markup_text = ""
40
+ for idx in range(len(self._tokens)):
41
+ if idx > 0:
42
+ if isinstance(self._tokens[idx - 1], TextToken) and isinstance(self._tokens[idx], TextToken):
43
+ markup_text += "\n"
44
+
45
+ if isinstance(self._tokens[idx], TextToken):
46
+ markup_text += self._tokens[idx].text
47
+ else:
48
+ markup_text += f"[{self._tokens[idx].tag.name}]"
49
+
50
+ return markup_text
51
+
52
+ @property
53
+ def text_tokens(self) -> list[TextToken]:
54
+ return [token for token in self._tokens if isinstance(token, TextToken)]
55
+
56
+ def pad_width(self, width: int):
57
+ for token in self.text_tokens:
58
+ token.pad_width(width)
59
+
60
+ def append_empty_lines(self, count: int, width: int):
61
+ for _ in range(count):
62
+ self._tokens.append(TextToken(" " * width))
@@ -0,0 +1,128 @@
1
+ from rich.text import Text
2
+ from src.span_table.types import TableType, SpanType
3
+ from src.span_table.markup_text import MarkupText
4
+
5
+ def check_span(data: TableType, span: SpanType):
6
+ if not len(span) == 2 or not len(span[0]) == 2 or not len(span[1]) == 2:
7
+ raise Exception("Span must be tuple of tuples in format ((start row index, start column index), (end row index, end column index))")
8
+
9
+ start_span, end_span = span
10
+ if not (start_span[0] <= end_span[0] and start_span[1] <= end_span[1]):
11
+ raise Exception('Spans must be rectangular in shape.')
12
+
13
+ if not (0 <= start_span[0] <= end_span[0] < len(data)) or not (0 <= start_span[1] <= end_span[1] < len(data[0])):
14
+ raise Exception(f"{span} is out of bounds.")
15
+
16
+ def extend_span(start: tuple[int, int], end: tuple[int, int]) -> SpanType:
17
+ extended_span = []
18
+ for row_idx in range(start[0], end[0] + 1):
19
+ for col_idx in range(start[1], end[1] + 1):
20
+ extended_span.append((row_idx, col_idx))
21
+
22
+ return extended_span
23
+
24
+ def get_span(spans: list[SpanType], row: int, column: int) -> SpanType | None:
25
+ for i in range(len(spans)):
26
+ if (row, column) in spans[i]:
27
+ return spans[i]
28
+
29
+ return None
30
+
31
+ def convert_cells_to_spans(data: TableType, spans: list[SpanType]) -> list[SpanType]:
32
+ new_spans = []
33
+ for row in range(len(data)):
34
+ for column in range(len(data[0])):
35
+ span = get_span(spans, row, column)
36
+ if not span:
37
+ new_spans.append([(row, column)])
38
+
39
+ new_spans.extend(spans)
40
+ new_spans = list(sorted(new_spans))
41
+ return new_spans
42
+
43
+ def get_span_column_count(span: SpanType) -> int:
44
+ return max((span[-1][1] - span[0][1]) + 1, 1)
45
+
46
+ def get_span_row_count(span: SpanType) -> int:
47
+ return max((span[-1][0] - span[0][0]) + 1, 1)
48
+
49
+ def get_longest_line_length(text: MarkupText) -> int:
50
+ return len(max(text.plain.split("\n"), key=lambda line: len(line)))
51
+
52
+ def get_output_column_widths(table: TableType, spans: list[SpanType]) -> list[int]:
53
+ widths = [3 for _ in range(len(table[0]))]
54
+ for row in range(len(table)):
55
+ for column in range(len(table[row])):
56
+ span = get_span(spans, row, column)
57
+ column_count = get_span_column_count(span)
58
+ text_row = span[0][0]
59
+ text_column = span[0][1]
60
+ text = table[text_row][text_column]
61
+ length = get_longest_line_length(text)
62
+ if column_count == 1:
63
+ widths[column] = max(length, widths[column])
64
+ else:
65
+ end_column = text_column + column_count
66
+ available_space = sum(widths[text_column:end_column]) + column_count - 1
67
+ while length > available_space:
68
+ for i in range(text_column, end_column):
69
+ widths[i] += 1
70
+ available_space = sum(widths[text_column:end_column]) + column_count - 1
71
+ if length <= available_space:
72
+ break
73
+ return widths
74
+
75
+ def get_output_row_heights(table: TableType, spans: list[SpanType]) -> list[int]:
76
+ heights = [-1 for _ in table]
77
+ for row in range(len(table)):
78
+ for column in range(len(table[row])):
79
+ text = table[row][column]
80
+ span = get_span(spans, row, column)
81
+ row_count = get_span_row_count(span)
82
+ height = len(text.plain.split('\n'))
83
+ if row_count == 1:
84
+ heights[row] = max(height, heights[row])
85
+
86
+ for row in range(len(table)):
87
+ for column in range(len(table[row])):
88
+ text = table[row][column]
89
+ span = get_span(spans, row, column)
90
+ row_count = get_span_row_count(span)
91
+ height = len(text.plain.split('\n'))
92
+ if row_count > 1:
93
+ text_row = span[0][0]
94
+ text_column = span[0][1]
95
+ end_row = text_row + row_count
96
+ text = table[text_row][text_column]
97
+ height = len(text.plain.split('\n')) - (row_count - 1)
98
+ add_row = 0
99
+ while height > sum(heights[text_row:end_row]):
100
+ heights[text_row + add_row] += 1
101
+ if add_row + 1 < row_count:
102
+ add_row += 1
103
+ else:
104
+ add_row = 0
105
+ return heights
106
+
107
+ def get_span_char_width(span: SpanType, column_widths: list[int]) -> int:
108
+ start_column = span[0][1]
109
+ column_count = get_span_column_count(span)
110
+ total_width = 0
111
+
112
+ for i in range(start_column, start_column + column_count):
113
+ total_width += column_widths[i]
114
+
115
+ total_width += column_count - 1
116
+
117
+ return total_width
118
+
119
+ def get_span_char_height(span: SpanType, row_heights: list[int]) -> int:
120
+ start_row = span[0][0]
121
+ row_count = get_span_row_count(span)
122
+ total_height = 0
123
+
124
+ for i in range(start_row, start_row + row_count):
125
+ total_height += row_heights[i]
126
+
127
+ total_height += row_count - 1
128
+ return total_height
@@ -0,0 +1,54 @@
1
+ from rich import box as rich_box
2
+ from rich.text import Text
3
+
4
+ from src.span_table.span import (
5
+ extend_span,
6
+ check_span,
7
+ convert_cells_to_spans,
8
+ get_output_column_widths,
9
+ get_output_row_heights,
10
+ )
11
+ from src.span_table.cell import make_cell, merge_all_cells
12
+ from src.span_table.markup_text import MarkupText
13
+ from src.span_table.types import SpanType, TableType
14
+ from typing import Annotated
15
+
16
+ TableMarkupType = Annotated[list[list[MarkupText]], 'Lists of list of markup type objects']
17
+
18
+ def check_table(data: TableType):
19
+ if not type(data) is list:
20
+ raise Exception("Table data must be a list of lists")
21
+
22
+ if len(data) == 0:
23
+ raise Exception("Table data must contain at least one row and one column")
24
+
25
+ for i in range(len(data)):
26
+ if not type(data[i]) is list:
27
+ raise Exception("Table data must be a list of lists")
28
+
29
+ if not len(data[i]) == len(data[0]):
30
+ raise Exception("Each row must have the same number of columns")
31
+
32
+ def span_table(
33
+ data: TableType,
34
+ spans: list[SpanType],
35
+ box: rich_box.Box = rich_box.SQUARE
36
+ ) -> str:
37
+ check_table(data=data)
38
+ extended_spans = []
39
+ for span in spans:
40
+ check_span(data, span)
41
+ extended_spans.append(extend_span(*span))
42
+
43
+ text_data = [[MarkupText(cell) for cell in row] for row in data]
44
+ spans = convert_cells_to_spans(text_data, extended_spans)
45
+ widths = get_output_column_widths(text_data, spans)
46
+ heights = get_output_row_heights(text_data, spans)
47
+ cells = [
48
+ make_cell(text_data, span, widths, heights, box)
49
+ for span in spans
50
+ ]
51
+ cells = list(sorted(cells))
52
+ grid_table = merge_all_cells(cells, box)
53
+
54
+ return grid_table
@@ -0,0 +1,5 @@
1
+ from typing import Annotated
2
+
3
+ SpanType = Annotated[list[tuple[int, int]], "List of tuples"]
4
+ TableType = Annotated[list[list[str]], "List of list of strings with rich markup"]
5
+
@@ -0,0 +1,80 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.9"
4
+ resolution-markers = [
5
+ "python_full_version >= '3.10'",
6
+ "python_full_version < '3.10'",
7
+ ]
8
+
9
+ [[package]]
10
+ name = "markdown-it-py"
11
+ version = "3.0.0"
12
+ source = { registry = "https://pypi.org/simple" }
13
+ resolution-markers = [
14
+ "python_full_version < '3.10'",
15
+ ]
16
+ dependencies = [
17
+ { name = "mdurl", marker = "python_full_version < '3.10'" },
18
+ ]
19
+ sdist = { url = "https://files.pythonhosted.org/packages/38/71/3b932df36c1a044d397a1f92d1cf91ee0a503d91e470cbd670aa66b07ed0/markdown-it-py-3.0.0.tar.gz", hash = "sha256:e3f60a94fa066dc52ec76661e37c851cb232d92f9886b15cb560aaada2df8feb", size = 74596, upload-time = "2023-06-03T06:41:14.443Z" }
20
+ wheels = [
21
+ { url = "https://files.pythonhosted.org/packages/42/d7/1ec15b46af6af88f19b8e5ffea08fa375d433c998b8a7639e76935c14f1f/markdown_it_py-3.0.0-py3-none-any.whl", hash = "sha256:355216845c60bd96232cd8d8c40e8f9765cc86f46880e43a8fd22dc1a1a8cab1", size = 87528, upload-time = "2023-06-03T06:41:11.019Z" },
22
+ ]
23
+
24
+ [[package]]
25
+ name = "markdown-it-py"
26
+ version = "4.2.0"
27
+ source = { registry = "https://pypi.org/simple" }
28
+ resolution-markers = [
29
+ "python_full_version >= '3.10'",
30
+ ]
31
+ dependencies = [
32
+ { name = "mdurl", marker = "python_full_version >= '3.10'" },
33
+ ]
34
+ sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
35
+ wheels = [
36
+ { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
37
+ ]
38
+
39
+ [[package]]
40
+ name = "mdurl"
41
+ version = "0.1.2"
42
+ source = { registry = "https://pypi.org/simple" }
43
+ sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
44
+ wheels = [
45
+ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
46
+ ]
47
+
48
+ [[package]]
49
+ name = "pygments"
50
+ version = "2.20.0"
51
+ source = { registry = "https://pypi.org/simple" }
52
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
53
+ wheels = [
54
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
55
+ ]
56
+
57
+ [[package]]
58
+ name = "rich"
59
+ version = "15.0.0"
60
+ source = { registry = "https://pypi.org/simple" }
61
+ dependencies = [
62
+ { name = "markdown-it-py", version = "3.0.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" },
63
+ { name = "markdown-it-py", version = "4.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
64
+ { name = "pygments" },
65
+ ]
66
+ sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
67
+ wheels = [
68
+ { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
69
+ ]
70
+
71
+ [[package]]
72
+ name = "span-table"
73
+ version = "0.1.0"
74
+ source = { editable = "." }
75
+ dependencies = [
76
+ { name = "rich" },
77
+ ]
78
+
79
+ [package.metadata]
80
+ requires-dist = [{ name = "rich", specifier = ">=15.0.0" }]