atlassian-cli 0.10.3__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.3/src/atlassian_cli.egg-info → atlassian_cli-0.10.4}/PKG-INFO +1 -1
  2. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/pyproject.toml +1 -1
  3. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/adf.py +169 -15
  4. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4/src/atlassian_cli.egg-info}/PKG-INFO +1 -1
  5. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/tests/test_adf.py +121 -0
  6. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/LICENSE +0 -0
  7. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/README.md +0 -0
  8. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/setup.cfg +0 -0
  9. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/__init__.py +0 -0
  10. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/config.py +0 -0
  11. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/confluence.py +0 -0
  12. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/hints.py +0 -0
  13. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/http.py +0 -0
  14. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/jira.py +0 -0
  15. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/jira_assets.py +0 -0
  16. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/jira_issues.py +0 -0
  17. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/output.py +0 -0
  18. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli/update_check.py +0 -0
  19. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli.egg-info/SOURCES.txt +0 -0
  20. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli.egg-info/dependency_links.txt +0 -0
  21. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli.egg-info/entry_points.txt +0 -0
  22. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli.egg-info/requires.txt +0 -0
  23. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/src/atlassian_cli.egg-info/top_level.txt +0 -0
  24. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/tests/test_config.py +0 -0
  25. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/tests/test_confluence.py +0 -0
  26. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/tests/test_http.py +0 -0
  27. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/tests/test_jira_assets.py +0 -0
  28. {atlassian_cli-0.10.3 → atlassian_cli-0.10.4}/tests/test_jira_issues.py +0 -0
  29. {atlassian_cli-0.10.3 → 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.3
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "atlassian-cli"
7
- version = "0.10.3"
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: atlassian-cli
3
- Version: 0.10.3
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
@@ -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
File without changes