atlassian-cli 0.10.2__tar.gz → 0.10.4__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.
Files changed (29) hide show
  1. {atlassian_cli-0.10.2/src/atlassian_cli.egg-info → atlassian_cli-0.10.4}/PKG-INFO +3 -1
  2. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/README.md +2 -0
  3. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/pyproject.toml +1 -1
  4. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/adf.py +169 -15
  5. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/confluence.py +14 -4
  6. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4/src/atlassian_cli.egg-info}/PKG-INFO +3 -1
  7. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/tests/test_adf.py +121 -0
  8. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/LICENSE +0 -0
  9. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/setup.cfg +0 -0
  10. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/__init__.py +0 -0
  11. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/config.py +0 -0
  12. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/hints.py +0 -0
  13. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/http.py +0 -0
  14. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/jira.py +0 -0
  15. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/jira_assets.py +0 -0
  16. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/jira_issues.py +0 -0
  17. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/output.py +0 -0
  18. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli/update_check.py +0 -0
  19. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli.egg-info/SOURCES.txt +0 -0
  20. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli.egg-info/dependency_links.txt +0 -0
  21. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli.egg-info/entry_points.txt +0 -0
  22. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli.egg-info/requires.txt +0 -0
  23. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/src/atlassian_cli.egg-info/top_level.txt +0 -0
  24. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/tests/test_config.py +0 -0
  25. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/tests/test_confluence.py +0 -0
  26. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/tests/test_http.py +0 -0
  27. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/tests/test_jira_assets.py +0 -0
  28. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/tests/test_jira_issues.py +0 -0
  29. {atlassian_cli-0.10.2 → atlassian_cli-0.10.4}/tests/test_output.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atlassian-cli
3
- Version: 0.10.2
3
+ Version: 0.10.4
4
4
  Summary: Fast CLI tools for Atlassian Cloud (Confluence + Jira) — optimized for AI agents
5
5
  Author-email: Alex Fishlock <alex.fishlock@catapult.cx>
6
6
  License-Expression: MIT
@@ -136,6 +136,7 @@ Create, read, update, and delete blog posts in any Confluence space.
136
136
  confluence blog-create POL "Q1 Product Update"
137
137
  confluence blog-create POL "Release Notes" --body "What's new this quarter..."
138
138
  confluence blog-create POL "Deep Dive" --file /path/to/adf.json
139
+ confluence blog-create POL "Retrospective" --body "..." --created-at 2024-01-15T10:00:00Z
139
140
 
140
141
  # Download a blog post
141
142
  confluence blog-get 123456
@@ -148,6 +149,7 @@ confluence blog-put 123456 --message "Added screenshots"
148
149
  # Update title and/or body directly
149
150
  confluence blog-update 123456 --title "Q1 Update (Revised)"
150
151
  confluence blog-update 123456 --body "Updated content here" -m "Corrections"
152
+ confluence blog-update 123456 --created-at "2024-06-01T08:00:00Z"
151
153
 
152
154
  # List blog posts in a space
153
155
  confluence blog-list POL
@@ -105,6 +105,7 @@ Create, read, update, and delete blog posts in any Confluence space.
105
105
  confluence blog-create POL "Q1 Product Update"
106
106
  confluence blog-create POL "Release Notes" --body "What's new this quarter..."
107
107
  confluence blog-create POL "Deep Dive" --file /path/to/adf.json
108
+ confluence blog-create POL "Retrospective" --body "..." --created-at 2024-01-15T10:00:00Z
108
109
 
109
110
  # Download a blog post
110
111
  confluence blog-get 123456
@@ -117,6 +118,7 @@ confluence blog-put 123456 --message "Added screenshots"
117
118
  # Update title and/or body directly
118
119
  confluence blog-update 123456 --title "Q1 Update (Revised)"
119
120
  confluence blog-update 123456 --body "Updated content here" -m "Corrections"
121
+ confluence blog-update 123456 --created-at "2024-06-01T08:00:00Z"
120
122
 
121
123
  # List blog posts in a space
122
124
  confluence blog-list POL
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "atlassian-cli"
7
- version = "0.10.2"
7
+ version = "0.10.4"
8
8
  description = "Fast CLI tools for Atlassian Cloud (Confluence + Jira) — optimized for AI agents"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -17,6 +17,7 @@ Provides:
17
17
  """
18
18
 
19
19
  import re
20
+ import uuid
20
21
 
21
22
  from atlas_doc_parser.api import NodeDoc
22
23
 
@@ -297,22 +298,63 @@ def _to_list_item(item):
297
298
  return {'type': 'listItem', 'content': [para(str(item))]}
298
299
 
299
300
 
300
- def table(header_cells, rows):
301
- """Create a table. header_cells/rows: lists of strings or inline-node lists."""
302
- def cell(val, is_header=False):
301
+ def table(header_cells, rows, *, colwidths=None, localid=None):
302
+ """Create a table. header_cells/rows: lists of strings or inline-node lists.
303
+
304
+ Args:
305
+ header_cells: Column headings (strings or inline-node lists).
306
+ rows: Body rows, each a list of cell values.
307
+ colwidths: Optional list of column widths (int/float). If omitted,
308
+ divides 900 equally.
309
+ localid: Optional table-level UUID. If omitted, generated.
310
+ """
311
+ n_cols = len(header_cells)
312
+ if colwidths is None:
313
+ colwidths = [round(900.0 / n_cols, 1)] * n_cols
314
+
315
+ def _uuid():
316
+ return str(uuid.uuid4())
317
+
318
+ def cell(val, col_idx, is_header=False, row_localid=None):
303
319
  ct = 'tableHeader' if is_header else 'tableCell'
320
+ colwidth = colwidths[col_idx] if col_idx < len(colwidths) else colwidths[-1]
321
+ attrs = {'colspan': 1, 'colwidth': colwidth, 'rowspan': 1}
304
322
  if isinstance(val, str):
305
- return {'type': ct, 'attrs': {}, 'content': [para(val)]}
306
- if isinstance(val, list):
307
- return {'type': ct, 'attrs': {}, 'content': [para(*val)]}
308
- return {'type': ct, 'attrs': {}, 'content': [para(str(val))]}
309
-
310
- content = [{'type': 'tableRow', 'content': [cell(c, True) for c in header_cells]}]
311
- for row in rows:
312
- content.append({'type': 'tableRow', 'content': [cell(c) for c in row]})
323
+ content_nodes = [para(val)]
324
+ elif isinstance(val, list):
325
+ content_nodes = [para(*val)]
326
+ else:
327
+ content_nodes = [para(str(val))]
328
+ node = {
329
+ 'type': ct,
330
+ 'attrs': attrs,
331
+ 'content': content_nodes,
332
+ 'localId': _uuid(),
333
+ }
334
+ return node
335
+
336
+ tbl_localid = localid or _uuid()
337
+
338
+ content = [
339
+ {
340
+ 'type': 'tableRow',
341
+ 'content': [cell(c, ci, is_header=True) for ci, c in enumerate(header_cells)],
342
+ 'localId': _uuid(),
343
+ }
344
+ ]
345
+ for ri, row in enumerate(rows):
346
+ row_localid = _uuid()
347
+ cells = []
348
+ for ci, c in enumerate(row):
349
+ cells.append(cell(c, ci, is_header=False))
350
+ content.append({
351
+ 'type': 'tableRow',
352
+ 'content': cells,
353
+ 'localId': row_localid,
354
+ })
313
355
  return {
314
356
  'type': 'table',
315
- 'attrs': {'isNumberColumnEnabled': False, 'layout': 'default', 'localId': ''},
357
+ 'attrs': {'isNumberColumnEnabled': False, 'layout': 'default', 'localId': tbl_localid},
316
358
  'content': content,
317
359
  }
318
360
 
@@ -342,8 +384,8 @@ def md_to_adf(markdown):
342
384
  """Convert a markdown string to a list of ADF nodes.
343
385
 
344
386
  Supports: headings, paragraphs, bullet/ordered lists, bold, italic,
345
- bold+italic, inline code, links, horizontal rules, code blocks, blockquotes.
346
- Tables should use the table() builder instead.
387
+ bold+italic, inline code, links, horizontal rules, code blocks, blockquotes,
388
+ tables (newline-separated and inline-flattened forms).
347
389
  """
348
390
  lines = markdown.split('\n')
349
391
  nodes = []
@@ -414,17 +456,129 @@ def md_to_adf(markdown):
414
456
  ]})
415
457
  continue
416
458
 
459
+ # Table (newline-separated Form A)
460
+ if line.startswith('|'):
461
+ tbl_result = _try_parse_table(lines, i)
462
+ if tbl_result:
463
+ nodes.append(tbl_result[0])
464
+ i = tbl_result[1]
465
+ continue
466
+
417
467
  # Paragraph — consecutive non-blank, non-block lines
418
468
  para_lines = []
419
469
  while i < len(lines) and lines[i].strip() and not _is_block_start(lines[i]):
420
470
  para_lines.append(lines[i])
421
471
  i += 1
422
472
  if para_lines:
423
- nodes.append({'type': 'paragraph', 'content': _parse_inline(' '.join(para_lines))})
473
+ joined = ' '.join(para_lines)
474
+ # Table (inline-flattened Form B) — detect inside paragraph
475
+ if _looks_like_inline_table(joined):
476
+ tbl_node = _parse_inline_table(joined)
477
+ if tbl_node:
478
+ nodes.append(tbl_node)
479
+ continue
480
+ nodes.append({'type': 'paragraph', 'content': _parse_inline(joined)})
424
481
 
425
482
  return nodes
426
483
 
427
484
 
485
+ _SEP_RE = re.compile(r'^:?-+:?$')
486
+
487
+
488
+ def _is_separator_row(cell_texts):
489
+ """Return True if every non-empty cell matches the separator pattern."""
490
+ return all(_SEP_RE.match(c.strip()) for c in cell_texts if c.strip())
491
+
492
+
493
+ def _split_row(line):
494
+ """Split a ``|...|...|`` markdown table row into cell strings."""
495
+ parts = line.strip().split('|')
496
+ # first and last are empty (leading/trailing pipe)
497
+ if parts and parts[0] == '':
498
+ parts = parts[1:]
499
+ if parts and parts[-1] == '':
500
+ parts = parts[:-1]
501
+ return [p.strip() for p in parts]
502
+
503
+
504
+ def _looks_like_inline_table(text_str):
505
+ """Check if a single-line paragraph looks like a flattened markdown table."""
506
+ # Must contain a separator pattern: |---| somewhere
507
+ if not re.search(r'\|\s*:?-{3,}:?\s*\|', text_str):
508
+ return False
509
+ # Must have at least 2 pipes (3 cells minimum after split)
510
+ return text_str.count('|') >= 4
511
+
512
+
513
+ def _parse_inline_table(text_str):
514
+ """Parse an inline-flattened markdown table into an ADF table node.
515
+
516
+ Row boundaries are ``|`` followed by whitespace then ``|`` (closing pipe
517
+ of one row immediately followed by opening pipe of the next).
518
+ """
519
+ raw_rows = re.split(r'\|\s+(?=\|)', text_str)
520
+ return _build_table_from_rows(raw_rows)
521
+
522
+
523
+ def _try_parse_table(lines, start):
524
+ """Try to parse a newline-separated markdown table starting at *start*.
525
+
526
+ Returns ``(table_node, new_index)`` or ``None`` if not a valid table.
527
+ """
528
+ i = start
529
+ raw_rows = []
530
+ while i < len(lines) and lines[i].strip().startswith('|'):
531
+ raw_rows.append(lines[i].strip())
532
+ i += 1
533
+
534
+ if len(raw_rows) < 2:
535
+ return None
536
+ # The second row must be a separator
537
+ cells = _split_row(raw_rows[1])
538
+ if not _is_separator_row(cells):
539
+ return None
540
+
541
+ return _build_table_from_rows(raw_rows), i
542
+
543
+
544
+ def _build_table_from_rows(raw_rows):
545
+ """Build an ADF table node from a list of ``|...|`` row strings.
546
+
547
+ First row = header, second row = separator (dropped), remainder = body.
548
+ """
549
+ if len(raw_rows) < 2:
550
+ return None
551
+
552
+ header_cells = _split_row(raw_rows[0])
553
+ n_cols = len(header_cells)
554
+
555
+ body_cells = []
556
+ for ri in range(2, len(raw_rows)):
557
+ row = _split_row(raw_rows[ri])
558
+ if not any(r.strip() for r in row):
559
+ continue
560
+ if len(row) < n_cols:
561
+ row.extend([''] * (n_cols - len(row)))
562
+ elif len(row) > n_cols:
563
+ row = row[:n_cols]
564
+ body_cells.append(row)
565
+
566
+ # Parse inline content for header cells, add strong mark
567
+ parsed_header = []
568
+ for h in header_cells:
569
+ inlines = _parse_inline(h)
570
+ for node in inlines:
571
+ if node.get('type') == 'text':
572
+ node.setdefault('marks', []).insert(0, {'type': 'strong'})
573
+ parsed_header.append(inlines)
574
+
575
+ parsed_body = []
576
+ for row in body_cells:
577
+ parsed_body.append([_parse_inline(c) for c in row])
578
+
579
+ return table(parsed_header, parsed_body)
580
+
581
+
428
582
  def _is_block_start(line):
429
583
  if re.match(r'^#{1,6}\s', line):
430
584
  return True
@@ -117,7 +117,8 @@ def list_pages(session, base, space_id, statuses=('current',)):
117
117
  # Blog posts (v2)
118
118
  # ---------------------------------------------------------------------------
119
119
 
120
- def create_blogpost(session, base, space_id, title, *, body=None, file=None):
120
+ def create_blogpost(session, base, space_id, title, *, body=None, file=None,
121
+ created_at=None):
121
122
  """Create a blog post in a Confluence space."""
122
123
  body_payload = {}
123
124
  if file:
@@ -144,6 +145,8 @@ def create_blogpost(session, base, space_id, title, *, body=None, file=None):
144
145
  }
145
146
  if body_payload:
146
147
  payload['body'] = body_payload
148
+ if created_at is not None:
149
+ payload['createdAt'] = created_at
147
150
 
148
151
  return api_post(session, base, f'{V2}/blogposts', payload)
149
152
 
@@ -162,8 +165,9 @@ def get_blogpost(session, base, blogpost_id):
162
165
 
163
166
 
164
167
  def update_blogpost(session, base, blogpost_id, *,
165
- title=None, body=None, file=None, message=None):
166
- """Update a blog post's title and/or body."""
168
+ title=None, body=None, file=None, message=None,
169
+ created_at=None):
170
+ """Update a blog post's title, body, and/or creation date."""
167
171
  remote = get_blogpost(session, base, blogpost_id)
168
172
 
169
173
  new_title = title if title is not None else remote.get('title', '')
@@ -203,6 +207,8 @@ def update_blogpost(session, base, blogpost_id, *,
203
207
  }
204
208
  if body_payload:
205
209
  payload['body'] = body_payload
210
+ if created_at is not None:
211
+ payload['createdAt'] = created_at
206
212
 
207
213
  api_put(session, base, f'{V2}/blogposts/{blogpost_id}', payload)
208
214
  return new_title, new_version
@@ -506,7 +512,8 @@ def cmd_blog_create(args):
506
512
  space_key = space.get('key', args.space_key)
507
513
 
508
514
  result = create_blogpost(session, base, space_id, args.title,
509
- body=args.body, file=args.file)
515
+ body=args.body, file=args.file,
516
+ created_at=getattr(args, 'created_at', None))
510
517
  blogpost_id = result['id']
511
518
 
512
519
  if args.dir:
@@ -531,6 +538,7 @@ def cmd_blog_update(args):
531
538
  session, base, args.blogpost_id,
532
539
  title=args.title, body=args.body, file=args.file,
533
540
  message=getattr(args, 'message', None),
541
+ created_at=getattr(args, 'created_at', None),
534
542
  )
535
543
 
536
544
  if args.dir:
@@ -1311,6 +1319,7 @@ def main():
1311
1319
  p.add_argument('title', help='Blog post title')
1312
1320
  p.add_argument('--body', help='Plain text body')
1313
1321
  p.add_argument('--file', '-f', help='ADF JSON file for blog body')
1322
+ p.add_argument('--created-at', help='Creation date (ISO 8601, e.g. 2024-01-15T10:00:00Z)')
1314
1323
  p.add_argument('--dir', default='pages', help='Pages directory (default: pages)')
1315
1324
  p.set_defaults(func=cmd_blog_create)
1316
1325
 
@@ -1324,6 +1333,7 @@ def main():
1324
1333
  p.add_argument('--title', help='New title')
1325
1334
  p.add_argument('--body', help='New plain text body')
1326
1335
  p.add_argument('--file', '-f', help='ADF JSON file for new blog body')
1336
+ p.add_argument('--created-at', help='Override creation date (ISO 8601, e.g. 2024-01-15T10:00:00Z)')
1327
1337
  p.add_argument('--message', '-m', help='Version message')
1328
1338
  p.add_argument('--dir', default='pages', help='Pages directory (default: pages)')
1329
1339
  p.set_defaults(func=cmd_blog_update)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atlassian-cli
3
- Version: 0.10.2
3
+ Version: 0.10.4
4
4
  Summary: Fast CLI tools for Atlassian Cloud (Confluence + Jira) — optimized for AI agents
5
5
  Author-email: Alex Fishlock <alex.fishlock@catapult.cx>
6
6
  License-Expression: MIT
@@ -136,6 +136,7 @@ Create, read, update, and delete blog posts in any Confluence space.
136
136
  confluence blog-create POL "Q1 Product Update"
137
137
  confluence blog-create POL "Release Notes" --body "What's new this quarter..."
138
138
  confluence blog-create POL "Deep Dive" --file /path/to/adf.json
139
+ confluence blog-create POL "Retrospective" --body "..." --created-at 2024-01-15T10:00:00Z
139
140
 
140
141
  # Download a blog post
141
142
  confluence blog-get 123456
@@ -148,6 +149,7 @@ confluence blog-put 123456 --message "Added screenshots"
148
149
  # Update title and/or body directly
149
150
  confluence blog-update 123456 --title "Q1 Update (Revised)"
150
151
  confluence blog-update 123456 --body "Updated content here" -m "Corrections"
152
+ confluence blog-update 123456 --created-at "2024-06-01T08:00:00Z"
151
153
 
152
154
  # List blog posts in a space
153
155
  confluence blog-list POL
@@ -492,3 +492,124 @@ More text.
492
492
  assert 'Test' in md_out
493
493
  assert 'world' in md_out
494
494
  assert 'item one' in md_out
495
+
496
+
497
+ # ---------------------------------------------------------------------------
498
+ # md_to_adf — table support
499
+ # ---------------------------------------------------------------------------
500
+
501
+ class TestMdToAdfTables:
502
+ def test_md_table_standard(self):
503
+ """Newline-separated markdown table produces valid ADF table."""
504
+ md = "\n".join([
505
+ "| Header 1 | Header 2 | Header 3 |",
506
+ "| --- | --- | --- |",
507
+ "| a | b | c |",
508
+ "| d | e | f |",
509
+ ])
510
+ nodes = md_to_adf(md)
511
+ assert len(nodes) == 1
512
+ t = nodes[0]
513
+ assert t['type'] == 'table'
514
+ assert 'localId' in t['attrs']
515
+ assert t['attrs']['localId'] != ''
516
+ rows = t['content']
517
+ assert len(rows) == 3 # header + 2 data, separator dropped
518
+ assert rows[0]['type'] == 'tableRow'
519
+ assert 'localId' in rows[0]
520
+ assert rows[0]['localId'] != ''
521
+ hdr = rows[0]['content'][0]
522
+ assert hdr['type'] == 'tableHeader'
523
+ assert 'localId' in hdr
524
+ assert hdr['localId'] != ''
525
+ hdr_text = hdr['content'][0]['content'][0]
526
+ assert hdr_text['marks'][0]['type'] == 'strong'
527
+ body_cell = rows[1]['content'][0]
528
+ assert body_cell['type'] == 'tableCell'
529
+ assert body_cell['localId'] != ''
530
+ assert 'colwidth' in body_cell['attrs']
531
+
532
+ def test_md_table_inline_flattened(self):
533
+ """Inline-flattened table (bug we hit) produces same ADF table."""
534
+ md = "| Header 1 | Header 2 | Header 3 | | --- | --- | --- | | a | b | c | | d | e | f |"
535
+ nodes = md_to_adf(md)
536
+ assert len(nodes) == 1
537
+ t = nodes[0]
538
+ assert t['type'] == 'table'
539
+ rows = t['content']
540
+ assert len(rows) == 3
541
+ assert rows[0]['content'][0]['type'] == 'tableHeader'
542
+ assert rows[1]['content'][0]['type'] == 'tableCell'
543
+
544
+ def test_md_table_inline_formatting(self):
545
+ """Cells with bold, italic, code, links produce correct inline marks."""
546
+ md = "| A | B | | --- | --- | | **bold** | *italic* | | `code` | [link](https://example.com) |"
547
+ nodes = md_to_adf(md)
548
+ t = nodes[0]
549
+ cells = t['content'][1]['content']
550
+ c0 = cells[0]['content'][0]['content'][0]
551
+ c1 = cells[1]['content'][0]['content'][0]
552
+ c2 = t['content'][2]['content'][0]['content'][0]['content'][0]
553
+ c3 = t['content'][2]['content'][1]['content'][0]['content'][0]
554
+ assert c0['marks'][0]['type'] == 'strong'
555
+ assert c1['marks'][0]['type'] == 'em'
556
+ assert c2['marks'][0]['type'] == 'code'
557
+ assert c3['marks'][0]['type'] == 'link'
558
+
559
+ def test_md_table_alignment_separator(self):
560
+ """Alignment markers in separator (:--, :-:, --:) are recognised and dropped."""
561
+ md = "\n".join([
562
+ "| L | C | R |",
563
+ "| :-- | :-: | --: |",
564
+ "| a | b | c |",
565
+ ])
566
+ nodes = md_to_adf(md)
567
+ t = nodes[0]
568
+ assert len(t['content']) == 2 # header + 1 data, separator dropped
569
+
570
+ def test_md_table_ragged_rows(self):
571
+ """Short rows pad with empty cells; long rows truncate."""
572
+ md = "\n".join([
573
+ "| A | B | C |",
574
+ "| --- | --- | --- |",
575
+ "| 1 | 2 |", # short
576
+ "| 4 | 5 | 6 | 7 |", # long
577
+ ])
578
+ nodes = md_to_adf(md)
579
+ t = nodes[0]
580
+ r1 = t['content'][1]['content'] # short row
581
+ r2 = t['content'][2]['content'] # long row
582
+ assert len(r1) == 3
583
+ assert len(r2) == 3
584
+
585
+ def test_md_table_in_mixed_document(self):
586
+ """Table sits between heading and bullet list, all separate nodes."""
587
+ md = "\n".join([
588
+ "## Before",
589
+ "",
590
+ "| A | B |",
591
+ "| --- | --- |",
592
+ "| 1 | 2 |",
593
+ "",
594
+ "- item one",
595
+ "- item two",
596
+ ])
597
+ nodes = md_to_adf(md)
598
+ types = [n['type'] for n in nodes]
599
+ assert types == ['heading', 'table', 'bulletList']
600
+
601
+ def test_md_table_localids_present(self):
602
+ """Every table/row/header/cell has a non-empty localId."""
603
+ md = "\n".join([
604
+ "| A | B |",
605
+ "| --- | --- |",
606
+ "| 1 | 2 |",
607
+ ])
608
+ nodes = md_to_adf(md)
609
+ t = nodes[0]
610
+ assert t['attrs']['localId'] != ''
611
+ for row in t['content']:
612
+ assert row['localId'] != ''
613
+ for cell in row['content']:
614
+ assert cell['localId'] != ''
615
+ assert 'colwidth' in cell['attrs']
File without changes
File without changes