Skip to main content

Convert nested dicts to Excel-style merged cell coordinates

Project description

listtocell

Convert nested Python dicts into Excel-style merged-cell coordinates (A1, A1:C1, A1:B2).

Useful for building multi-level table headers with merged cells in spreadsheet libraries like openpyxl or xlsxwriter — without manually calculating ranges.

Installation

pip install listtocell
# or
uv add listtocell

Quick start

from listtocell import get_cells

result = get_cells({
    "Person": {"Name": "John", "Age": 25},
    "Score": 99,
})
[
  {"cell": "A1", "range": "A1:B1", "value": "Person", "column": "Person"},
  {"cell": "A2", "range": "A2:A2", "value": "John", "column": "Name"},
  {"cell": "B2", "range": "B2:B2", "value": 25, "column": "Age"},
  {"cell": "C1", "range": "C1:C2", "value": 99, "column": "Score"}
]

Each entry contains:

Key Description
cell Top-left cell of the merge (e.g. A1)
range Full merge range (e.g. A1:B1)
value Cell content
column Original dict key

How it works

Flat dict — single header row

get_cells({"Name": "John", "Age": 25})
[
  {"cell": "A1", "range": "A1:A1", "value": "John", "column": "Name"},
  {"cell": "B1", "range": "B1:B1", "value": 25, "column": "Age"}
]

Nested dict — merged parent + child row

get_cells({"Person": {"Name": "John", "Age": 25}})
[
  {"cell": "A1", "range": "A1:B1", "value": "Person", "column": "Person"},
  {"cell": "A2", "range": "A2:A2", "value": "John", "column": "Name"},
  {"cell": "B2", "range": "B2:B2", "value": 25, "column": "Age"}
]

Mixed depth — flat value spans remaining rows

When a flat value sits alongside nested values, it automatically rowspans to fill the depth:

get_cells({"Group": {"X": 1, "Y": 2}, "Total": 99})
[
  {"cell": "A1", "range": "A1:B1", "value": "Group", "column": "Group"},
  {"cell": "A2", "range": "A2:A2", "value": 1, "column": "X"},
  {"cell": "B2", "range": "B2:B2", "value": 2, "column": "Y"},
  {"cell": "C1", "range": "C1:C2", "value": 99, "column": "Total"}
]

Multiple nesting levels

get_cells({"Report": {"Summary": {"Total": 100, "Avg": 50}}})
[
  {"cell": "A1", "range": "A1:B1", "value": "Report", "column": "Report"},
  {"cell": "A2", "range": "A2:B2", "value": "Summary", "column": "Summary"},
  {"cell": "A3", "range": "A3:A3", "value": 100, "column": "Total"},
  {"cell": "B3", "range": "B3:B3", "value": 50, "column": "Avg"}
]

List as value — expanded like a dict

Lists are treated as nested structures with integer keys:

get_cells({"Tags": ["python", "excel"]})
[
  {"cell": "A1", "range": "A1:B1", "value": "Tags", "column": "Tags"},
  {"cell": "A2", "range": "A2:A2", "value": "python", "column": 0},
  {"cell": "B2", "range": "B2:B2", "value": "excel", "column": 1}
]

Use cases

openpyxl — grouped headers

from openpyxl import Workbook
from openpyxl.styles import Alignment
from listtocell import get_cells

wb = Workbook()
ws = wb.active

headers = {
    "Personal": {"Name": "Alice", "Age": 30},
    "Financial": {"Salary": 5000, "Bonus": 500, "Total": 5500},
}

for entry in get_cells(headers):
    ws[entry["cell"]] = entry["value"]
    if entry["cell"] != entry["range"].split(":")[1]:  # is a merge
        ws.merge_cells(entry["range"])
        ws[entry["cell"]].alignment = Alignment(horizontal="center")

wb.save("report.xlsx")

xlsxwriter — grouped headers

import xlsxwriter
from listtocell import get_cells

workbook = xlsxwriter.Workbook("report.xlsx")
ws = workbook.add_worksheet()
merge_fmt = workbook.add_format({"align": "center", "bold": True})

headers = {
    "Q1": {"Jan": 100, "Feb": 120, "Mar": 110},
    "Q2": {"Apr": 130, "May": 140, "Jun": 150},
}

for entry in get_cells(headers):
    cell_start, cell_end = entry["range"].split(":")
    if cell_start == cell_end:
        ws.write(entry["cell"], entry["value"])
    else:
        ws.merge_range(entry["range"], entry["value"], merge_fmt)

workbook.close()

Dynamic headers from config

Headers can be built programmatically from any data source — database schema, API response, config file:

from listtocell import get_cells

# Config driven
schema = {
    "user": {"id": None, "email": None, "role": None},
    "audit": {"created_at": None, "updated_at": None},
}

cells = get_cells(schema)
# Use cells to render a table header in any output format

Row offset (range_start)

When writing below existing content (e.g. a title row), shift all row numbers:

result = get_cells({"Group": {"X": 1, "Y": 2}}, range_start=3)
[
  {"cell": "A3", "range": "A3:B3", "value": "Group", "column": "Group"},
  {"cell": "A4", "range": "A4:A4", "value": 1, "column": "X"},
  {"cell": "B4", "range": "B4:B4", "value": 2, "column": "Y"}
]

Column span (colspan / Span)

Two ways to make a leaf cell span multiple columns:

External dict:

from listtocell import get_cells

result = get_cells(
    {"Notes": "see below", "Group": {"X": 1, "Y": 2}},
    colspan={"Notes": 2},
)
[
  {"cell": "A1", "range": "A1:B2", "value": "see below", "column": "Notes"},
  {"cell": "C1", "range": "C1:D1", "value": "Group", "column": "Group"},
  {"cell": "C2", "range": "C2:C2", "value": 1, "column": "X"},
  {"cell": "D2", "range": "D2:D2", "value": 2, "column": "Y"}
]

Span inline — keeps the colspan next to the value:

from listtocell import get_cells, Span

result = get_cells({"Notes": Span("see below", 2), "Group": {"X": 1, "Y": 2}})
[
  {"cell": "A1", "range": "A1:B2", "value": "see below", "column": "Notes"},
  {"cell": "C1", "range": "C1:D1", "value": "Group", "column": "Group"},
  {"cell": "C2", "range": "C2:C2", "value": 1, "column": "X"},
  {"cell": "D2", "range": "D2:D2", "value": 2, "column": "Y"}
]

API reference

get_cells(arr, *, range_start=1, colspan=None)

Parameter Type Description
arr dict | list Header structure to traverse
range_start int First row number (default 1)
colspan dict Mapping of key → column span for leaf values

Span(value, span)

Wraps a leaf value with an explicit column span. Takes priority over the colspan dict when both are provided for the same key.

Listtocell (class)

Lower-level class for cases where you need to reuse an instance or set range_start separately:

from listtocell import Listtocell

ltc = Listtocell()
ltc.range_start = 2
result = ltc.get_cells({"A": {"X": 1, "Y": 2}})

License

MIT

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

listtocell-0.1.0.tar.gz (5.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

listtocell-0.1.0-py3-none-any.whl (5.0 kB view details)

Uploaded Python 3

File details

Details for the file listtocell-0.1.0.tar.gz.

File metadata

  • Download URL: listtocell-0.1.0.tar.gz
  • Upload date:
  • Size: 5.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.12

File hashes

Hashes for listtocell-0.1.0.tar.gz
Algorithm Hash digest
SHA256 0e26162e7e4778949e6d562ff5ce9f178f0690810a71d273a49d25ab41d93cfa
MD5 d9edd549c4fadd86c476c8e3e67763f2
BLAKE2b-256 c602007d5b3c3b5e56721eca15cc2577041628e42571c132e42c07c9bdeea62a

See more details on using hashes here.

File details

Details for the file listtocell-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: listtocell-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 5.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.12

File hashes

Hashes for listtocell-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 d7fd03482f3b0a875d0e969460f73e9930033b7fe1a14d1b4b857f6d62e40735
MD5 46dc8ea9279492253bef2da9a0d38344
BLAKE2b-256 4a7e7e75ab6b339372f5ef7220098747b541c5437a3081c4cc007ff1a2f6c43a

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page