bugdantic 0.2.8__tar.gz → 0.4.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.
- {bugdantic-0.2.8 → bugdantic-0.4.0}/PKG-INFO +2 -2
- {bugdantic-0.2.8 → bugdantic-0.4.0}/bugdantic/bugzilla.py +105 -14
- bugdantic-0.4.0/bugdantic/userstory.py +130 -0
- {bugdantic-0.2.8 → bugdantic-0.4.0}/bugdantic.egg-info/PKG-INFO +2 -2
- {bugdantic-0.2.8 → bugdantic-0.4.0}/bugdantic.egg-info/SOURCES.txt +3 -1
- {bugdantic-0.2.8 → bugdantic-0.4.0}/pyproject.toml +2 -2
- {bugdantic-0.2.8 → bugdantic-0.4.0}/test/test_bugzilla.py +27 -1
- bugdantic-0.4.0/test/test_userstory.py +59 -0
- {bugdantic-0.2.8 → bugdantic-0.4.0}/LICENSE +0 -0
- {bugdantic-0.2.8 → bugdantic-0.4.0}/README.md +0 -0
- {bugdantic-0.2.8 → bugdantic-0.4.0}/bugdantic/__init__.py +0 -0
- {bugdantic-0.2.8 → bugdantic-0.4.0}/bugdantic/py.typed +0 -0
- {bugdantic-0.2.8 → bugdantic-0.4.0}/bugdantic.egg-info/dependency_links.txt +0 -0
- {bugdantic-0.2.8 → bugdantic-0.4.0}/bugdantic.egg-info/requires.txt +0 -0
- {bugdantic-0.2.8 → bugdantic-0.4.0}/bugdantic.egg-info/top_level.txt +0 -0
- {bugdantic-0.2.8 → bugdantic-0.4.0}/setup.cfg +0 -0
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bugdantic
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Bugzilla client based on pydantic
|
|
5
5
|
Author-email: James Graham <james@hoppipolla.co.uk>
|
|
6
|
-
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
7
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
8
8
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
9
9
|
Requires-Python: >=3.11
|
|
@@ -4,10 +4,21 @@ import json
|
|
|
4
4
|
import logging
|
|
5
5
|
from dataclasses import dataclass
|
|
6
6
|
from datetime import datetime
|
|
7
|
-
from typing import
|
|
7
|
+
from typing import (
|
|
8
|
+
Any,
|
|
9
|
+
Generic,
|
|
10
|
+
Mapping,
|
|
11
|
+
MutableMapping,
|
|
12
|
+
Optional,
|
|
13
|
+
Sequence,
|
|
14
|
+
Self,
|
|
15
|
+
TypeVar,
|
|
16
|
+
cast,
|
|
17
|
+
)
|
|
8
18
|
from urllib.parse import urljoin
|
|
9
19
|
|
|
10
20
|
import httpx
|
|
21
|
+
import pydantic
|
|
11
22
|
from pydantic import BaseModel, ConfigDict
|
|
12
23
|
|
|
13
24
|
Json = dict[str, "Json"] | list["Json"] | str | int | float | bool | None
|
|
@@ -412,6 +423,13 @@ class AttachmentsCreateResponse(BaseModel):
|
|
|
412
423
|
attachments: Mapping[str, AttachmentCreateResponse]
|
|
413
424
|
|
|
414
425
|
|
|
426
|
+
class ErrorResponse(BaseModel):
|
|
427
|
+
code: int
|
|
428
|
+
message: str
|
|
429
|
+
error: bool
|
|
430
|
+
documentation: str
|
|
431
|
+
|
|
432
|
+
|
|
415
433
|
@dataclass
|
|
416
434
|
class BugzillaConfig:
|
|
417
435
|
base_url: str
|
|
@@ -422,6 +440,20 @@ class BugzillaConfig:
|
|
|
422
440
|
max_retries: int = 1
|
|
423
441
|
|
|
424
442
|
|
|
443
|
+
BugType = TypeVar("BugType", bound=BaseModel)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class BugSearchGeneric(BaseModel, Generic[BugType]):
|
|
447
|
+
bugs: Optional[list[BugType]] = None
|
|
448
|
+
faults: Optional[list[Any]] = None
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def bug_search_model(bug_type: type[BugType]) -> type[BugSearchGeneric[BugType]]:
|
|
452
|
+
return cast(
|
|
453
|
+
type[BugSearchGeneric[BugType]], BugSearchGeneric.__class_getitem__(bug_type)
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
|
|
425
457
|
class Bugzilla:
|
|
426
458
|
def __init__(self, config: BugzillaConfig):
|
|
427
459
|
self.config = config
|
|
@@ -491,13 +523,27 @@ class Bugzilla:
|
|
|
491
523
|
""")
|
|
492
524
|
return {}
|
|
493
525
|
|
|
494
|
-
def
|
|
495
|
-
|
|
496
|
-
|
|
526
|
+
def check_error(self, data: Mapping[str, Json]) -> Mapping[str, Json]:
|
|
527
|
+
try:
|
|
528
|
+
err = ErrorResponse.model_validate(data)
|
|
529
|
+
if err.error:
|
|
530
|
+
raise BugzillaError(err.message)
|
|
531
|
+
except pydantic.ValidationError:
|
|
532
|
+
pass
|
|
533
|
+
return data
|
|
534
|
+
|
|
535
|
+
def _bug(
|
|
536
|
+
self,
|
|
537
|
+
bug_id: int,
|
|
538
|
+
bug_type: type[BugType],
|
|
539
|
+
include_fields: Optional[list[str]] = None,
|
|
540
|
+
) -> Optional[BugType]:
|
|
497
541
|
"""Get a single bug specified by id"""
|
|
498
|
-
|
|
542
|
+
|
|
543
|
+
data = self.check_error(
|
|
499
544
|
self.request("GET", f"bug/{bug_id}", include_fields=include_fields)
|
|
500
545
|
)
|
|
546
|
+
search_result = bug_search_model(bug_type).model_validate(data)
|
|
501
547
|
if search_result.faults:
|
|
502
548
|
raise BugzillaError(search_result.faults)
|
|
503
549
|
bugs = search_result.bugs
|
|
@@ -506,6 +552,15 @@ class Bugzilla:
|
|
|
506
552
|
assert len(bugs) == 1
|
|
507
553
|
return bugs[0]
|
|
508
554
|
|
|
555
|
+
def bug(
|
|
556
|
+
self, bug_id: int, include_fields: Optional[list[str]] = None
|
|
557
|
+
) -> Optional[Bug]:
|
|
558
|
+
return self._bug(bug_id, Bug, include_fields)
|
|
559
|
+
|
|
560
|
+
def bug_as(self, bug_id: int, bug_type: type[BugType]) -> Optional[BugType]:
|
|
561
|
+
include_fields = list(bug_type.model_fields.keys())
|
|
562
|
+
return self._bug(bug_id, bug_type, include_fields)
|
|
563
|
+
|
|
509
564
|
def bugs(
|
|
510
565
|
self,
|
|
511
566
|
bug_ids: Sequence[int],
|
|
@@ -525,6 +580,25 @@ class Bugzilla:
|
|
|
525
580
|
)
|
|
526
581
|
return results
|
|
527
582
|
|
|
583
|
+
def bugs_as(
|
|
584
|
+
self,
|
|
585
|
+
bug_ids: Sequence[int],
|
|
586
|
+
bug_type: type[BugType],
|
|
587
|
+
page_size: int = 100,
|
|
588
|
+
) -> list[Bug]:
|
|
589
|
+
"""Get multiple bugs specified by id"""
|
|
590
|
+
results: list[Bug] = []
|
|
591
|
+
for bug_ids_chunk in [
|
|
592
|
+
bug_ids[n : n + page_size] for n in range(0, len(bug_ids), page_size)
|
|
593
|
+
]:
|
|
594
|
+
results.extend(
|
|
595
|
+
self.search_as(
|
|
596
|
+
{"id": ",".join(str(id) for id in bug_ids_chunk)},
|
|
597
|
+
bug_type,
|
|
598
|
+
)
|
|
599
|
+
)
|
|
600
|
+
return results
|
|
601
|
+
|
|
528
602
|
def bug_history(
|
|
529
603
|
self, bug_id: int, new_since: Optional[datetime] = None
|
|
530
604
|
) -> BugHistory:
|
|
@@ -532,9 +606,10 @@ class Bugzilla:
|
|
|
532
606
|
params = {}
|
|
533
607
|
if new_since is not None:
|
|
534
608
|
params["new_since"] = new_since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
535
|
-
|
|
609
|
+
data = self.check_error(
|
|
536
610
|
self.request("GET", f"bug/{bug_id}/history", params=params)
|
|
537
611
|
)
|
|
612
|
+
query_result = BugsHistory.model_validate(data)
|
|
538
613
|
if query_result.faults:
|
|
539
614
|
raise BugzillaError(query_result.faults)
|
|
540
615
|
bugs = query_result.bugs
|
|
@@ -543,13 +618,15 @@ class Bugzilla:
|
|
|
543
618
|
assert len(bugs) == 1
|
|
544
619
|
return bugs[0]
|
|
545
620
|
|
|
546
|
-
def
|
|
621
|
+
def _search(
|
|
547
622
|
self,
|
|
623
|
+
bug_type: type[BugType],
|
|
548
624
|
query: QueryParams,
|
|
549
625
|
include_fields: Optional[list[str]] = None,
|
|
550
626
|
page_size: int = 100,
|
|
551
|
-
) -> list[
|
|
627
|
+
) -> list[BugType]:
|
|
552
628
|
"""Search for bugs using the bugzilla query API"""
|
|
629
|
+
|
|
553
630
|
query = {**query}
|
|
554
631
|
paginate = False
|
|
555
632
|
offset = 0
|
|
@@ -558,12 +635,12 @@ class Bugzilla:
|
|
|
558
635
|
query["offset"] = "0"
|
|
559
636
|
paginate = True
|
|
560
637
|
|
|
561
|
-
results: list[
|
|
638
|
+
results: list[BugType] = []
|
|
562
639
|
while True:
|
|
563
|
-
response = self.
|
|
564
|
-
"GET", "bug", params=query, include_fields=include_fields
|
|
640
|
+
response = self.check_error(
|
|
641
|
+
self.request("GET", "bug", params=query, include_fields=include_fields)
|
|
565
642
|
)
|
|
566
|
-
search_result =
|
|
643
|
+
search_result = bug_search_model(bug_type).model_validate(response)
|
|
567
644
|
if search_result.faults:
|
|
568
645
|
raise BugzillaError(search_result.faults)
|
|
569
646
|
if search_result.bugs is not None:
|
|
@@ -581,6 +658,20 @@ class Bugzilla:
|
|
|
581
658
|
|
|
582
659
|
return results
|
|
583
660
|
|
|
661
|
+
def search(
|
|
662
|
+
self,
|
|
663
|
+
query: QueryParams,
|
|
664
|
+
include_fields: Optional[list[str]] = None,
|
|
665
|
+
page_size: int = 100,
|
|
666
|
+
) -> list[Bug]:
|
|
667
|
+
return self._search(Bug, query, include_fields, page_size)
|
|
668
|
+
|
|
669
|
+
def search_as(
|
|
670
|
+
self, query: QueryParams, bug_type: type[BugType], page_size: int = 100
|
|
671
|
+
):
|
|
672
|
+
include_fields = list(bug_type.model_fields.keys())
|
|
673
|
+
return self._search(bug_type, query, include_fields, page_size)
|
|
674
|
+
|
|
584
675
|
def update_bugs(
|
|
585
676
|
self, update_params: BugUpdate, bug_id: Optional[int] = None
|
|
586
677
|
) -> list[BugUpdateResponse]:
|
|
@@ -600,7 +691,7 @@ class Bugzilla:
|
|
|
600
691
|
|
|
601
692
|
json_body = update_params.model_dump(exclude_none=True)
|
|
602
693
|
|
|
603
|
-
response = self.request("PUT", path, json_body=json_body)
|
|
694
|
+
response = self.check_error(self.request("PUT", path, json_body=json_body))
|
|
604
695
|
|
|
605
696
|
if self.config.allow_writes:
|
|
606
697
|
update_result = BugsUpdateResponse.model_validate(response)
|
|
@@ -632,7 +723,7 @@ class Bugzilla:
|
|
|
632
723
|
if not json_body.get("ids"):
|
|
633
724
|
json_body["ids"] = [bug_id]
|
|
634
725
|
|
|
635
|
-
response = self.request("POST", path, json_body=json_body)
|
|
726
|
+
response = self.check_error(self.request("POST", path, json_body=json_body))
|
|
636
727
|
|
|
637
728
|
if self.config.allow_writes:
|
|
638
729
|
create_result = AttachmentsCreateResponse.model_validate(response)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Utility functions for working with cf_user_story fields that
|
|
2
|
+
are used to store key: value pair data"""
|
|
3
|
+
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Iterator, Optional, Mapping, Self
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def parse(
|
|
13
|
+
user_story: str,
|
|
14
|
+
) -> Iterator[tuple[str, Optional[str], Optional[str]]]:
|
|
15
|
+
"""Parse the user story assuming it's lines of the form key: value.
|
|
16
|
+
|
|
17
|
+
If there isn't a colon in the line we simply set value to the full line."""
|
|
18
|
+
user_story_re = re.compile(r"^\s*([^\s]+)\s*:\s*(.*)")
|
|
19
|
+
for line in user_story.splitlines():
|
|
20
|
+
key = None
|
|
21
|
+
value = None
|
|
22
|
+
m = user_story_re.match(line)
|
|
23
|
+
if m is not None:
|
|
24
|
+
maybe_key, maybe_value = m.groups()
|
|
25
|
+
if maybe_value:
|
|
26
|
+
key = maybe_key
|
|
27
|
+
value = maybe_value
|
|
28
|
+
yield line, key, value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def parse_as_dict(user_story: str) -> Mapping[str, str | list[str]]:
|
|
32
|
+
rv: dict[str, str | list[str]] = {}
|
|
33
|
+
for _, key, value in parse(user_story):
|
|
34
|
+
if key is not None and value is not None:
|
|
35
|
+
if key in rv:
|
|
36
|
+
current_value = rv[key]
|
|
37
|
+
if isinstance(current_value, list):
|
|
38
|
+
current_value.append(value)
|
|
39
|
+
else:
|
|
40
|
+
rv[key] = [current_value, value]
|
|
41
|
+
else:
|
|
42
|
+
rv[key] = value
|
|
43
|
+
return rv
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class UserStoryChangeType(IntEnum):
|
|
47
|
+
APPEND = 1
|
|
48
|
+
REPLACE = 2
|
|
49
|
+
DELETE = 3
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class UserStoryChange:
|
|
54
|
+
field: str
|
|
55
|
+
type: UserStoryChangeType
|
|
56
|
+
# In case of replace changes, the current value to replace or delete
|
|
57
|
+
old_value: Optional[str] = None
|
|
58
|
+
# In case of append or replace changes the new value to set
|
|
59
|
+
new_value: Optional[str] = None
|
|
60
|
+
|
|
61
|
+
@classmethod
|
|
62
|
+
def append(cls, field: str, new_value: str) -> Self:
|
|
63
|
+
return cls(field, UserStoryChangeType.APPEND, None, new_value)
|
|
64
|
+
|
|
65
|
+
@classmethod
|
|
66
|
+
def replace(cls, field: str, old_value: str, new_value: str) -> Self:
|
|
67
|
+
return cls(field, UserStoryChangeType.REPLACE, old_value, new_value)
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def delete(cls, field: str, old_value: str) -> Self:
|
|
71
|
+
return cls(field, UserStoryChangeType.DELETE, old_value, None)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def update(user_story: str, changes: list[UserStoryChange]) -> Optional[str]:
|
|
75
|
+
new_user_story = []
|
|
76
|
+
user_story_updates = defaultdict(list)
|
|
77
|
+
for change in changes:
|
|
78
|
+
user_story_updates[change.field].append(change)
|
|
79
|
+
|
|
80
|
+
has_updates = False
|
|
81
|
+
applied_changes = set()
|
|
82
|
+
|
|
83
|
+
for line, key, value in parse(user_story):
|
|
84
|
+
if key is None or value is None:
|
|
85
|
+
new_user_story.append(line)
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
output_line: Optional[tuple[str, str]] = (key, value)
|
|
89
|
+
if key in user_story_updates:
|
|
90
|
+
changes = user_story_updates[key]
|
|
91
|
+
current_value = value.strip()
|
|
92
|
+
for change in changes:
|
|
93
|
+
if change in applied_changes:
|
|
94
|
+
continue
|
|
95
|
+
if current_value == change.old_value:
|
|
96
|
+
applied_changes.add(change)
|
|
97
|
+
if change.type == UserStoryChangeType.DELETE:
|
|
98
|
+
output_line = None
|
|
99
|
+
has_updates = True
|
|
100
|
+
elif change.type == UserStoryChangeType.REPLACE:
|
|
101
|
+
assert change.new_value is not None
|
|
102
|
+
output_line = (key, change.new_value)
|
|
103
|
+
has_updates = True
|
|
104
|
+
elif (
|
|
105
|
+
change.type == UserStoryChangeType.APPEND
|
|
106
|
+
and current_value == change.new_value
|
|
107
|
+
):
|
|
108
|
+
# If we are going to append a value that's already there
|
|
109
|
+
# do nothing
|
|
110
|
+
applied_changes.add(change)
|
|
111
|
+
if output_line is not None:
|
|
112
|
+
new_user_story.append(f"{output_line[0]}:{output_line[1]}")
|
|
113
|
+
|
|
114
|
+
for changes in user_story_updates.values():
|
|
115
|
+
for change in changes:
|
|
116
|
+
if change not in applied_changes:
|
|
117
|
+
if change.type == UserStoryChangeType.DELETE:
|
|
118
|
+
# Tried to delete a key that doesn't exist, do nothing
|
|
119
|
+
pass
|
|
120
|
+
elif change.type == UserStoryChangeType.REPLACE:
|
|
121
|
+
# Tried to replace a key that doesn't exist, do nothing
|
|
122
|
+
pass
|
|
123
|
+
elif change.type == UserStoryChangeType.APPEND:
|
|
124
|
+
new_user_story.append(f"{change.field}:{change.new_value}")
|
|
125
|
+
has_updates = True
|
|
126
|
+
|
|
127
|
+
if has_updates:
|
|
128
|
+
return "\n".join(new_user_story) + "\n"
|
|
129
|
+
|
|
130
|
+
return None
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bugdantic
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Bugzilla client based on pydantic
|
|
5
5
|
Author-email: James Graham <james@hoppipolla.co.uk>
|
|
6
|
-
|
|
6
|
+
License-Expression: MPL-2.0
|
|
7
7
|
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
8
8
|
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
9
9
|
Requires-Python: >=3.11
|
|
@@ -4,9 +4,11 @@ pyproject.toml
|
|
|
4
4
|
bugdantic/__init__.py
|
|
5
5
|
bugdantic/bugzilla.py
|
|
6
6
|
bugdantic/py.typed
|
|
7
|
+
bugdantic/userstory.py
|
|
7
8
|
bugdantic.egg-info/PKG-INFO
|
|
8
9
|
bugdantic.egg-info/SOURCES.txt
|
|
9
10
|
bugdantic.egg-info/dependency_links.txt
|
|
10
11
|
bugdantic.egg-info/requires.txt
|
|
11
12
|
bugdantic.egg-info/top_level.txt
|
|
12
|
-
test/test_bugzilla.py
|
|
13
|
+
test/test_bugzilla.py
|
|
14
|
+
test/test_userstory.py
|
|
@@ -5,10 +5,10 @@ authors = [
|
|
|
5
5
|
{name = "James Graham", email = "james@hoppipolla.co.uk"}
|
|
6
6
|
]
|
|
7
7
|
|
|
8
|
-
version = "0.
|
|
8
|
+
version = "0.4.0"
|
|
9
9
|
requires-python = ">=3.11"
|
|
10
|
+
license = "MPL-2.0"
|
|
10
11
|
classifiers = [
|
|
11
|
-
"License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)",
|
|
12
12
|
"Programming Language :: Python :: Implementation :: CPython",
|
|
13
13
|
"Programming Language :: Python :: Implementation :: PyPy",
|
|
14
14
|
]
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import pytest
|
|
2
|
+
from pydantic import BaseModel
|
|
2
3
|
|
|
3
4
|
from bugdantic import Bugzilla, BugzillaConfig
|
|
5
|
+
from bugdantic.bugzilla import BugComment
|
|
4
6
|
|
|
5
7
|
|
|
6
8
|
@pytest.fixture
|
|
@@ -26,7 +28,7 @@ def test_bug_history_full(bugzilla):
|
|
|
26
28
|
assert result.id == 1886129
|
|
27
29
|
|
|
28
30
|
|
|
29
|
-
def
|
|
31
|
+
def test_search_include_history(bugzilla):
|
|
30
32
|
bugs = [423488, 1749533]
|
|
31
33
|
result = bugzilla.search({"id": bugs}, include_fields=["id", "history"])
|
|
32
34
|
for expected_id, bug in zip(bugs, sorted(result, key=lambda x: x.id)):
|
|
@@ -43,3 +45,27 @@ def test_search_include_comments_and_attachments(bugzilla):
|
|
|
43
45
|
assert bug.id == expected_id
|
|
44
46
|
assert isinstance(bug.comments, list)
|
|
45
47
|
assert isinstance(bug.attachments, list)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_bug_as(bugzilla):
|
|
51
|
+
class BugData(BaseModel):
|
|
52
|
+
id: int
|
|
53
|
+
cf_user_story: str
|
|
54
|
+
|
|
55
|
+
result = bugzilla.bug_as(975444, BugData)
|
|
56
|
+
assert isinstance(result, BugData)
|
|
57
|
+
assert result.id == 975444
|
|
58
|
+
assert result.cf_user_story is not None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_search_as(bugzilla):
|
|
62
|
+
class BugData(BaseModel):
|
|
63
|
+
id: int
|
|
64
|
+
comments: list[BugComment]
|
|
65
|
+
|
|
66
|
+
bugs = [423488, 1749533]
|
|
67
|
+
result = bugzilla.search_as({"id": bugs}, BugData)
|
|
68
|
+
for expected_id, bug in zip(bugs, sorted(result, key=lambda x: x.id)):
|
|
69
|
+
assert isinstance(bug, BugData)
|
|
70
|
+
assert bug.id == expected_id
|
|
71
|
+
assert isinstance(bug.comments, list)
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from bugdantic import userstory
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_parse_user_story():
|
|
5
|
+
user_story = """
|
|
6
|
+
foo:bar
|
|
7
|
+
abcde:
|
|
8
|
+
foo : baz
|
|
9
|
+
some long line: with a colon
|
|
10
|
+
key-:value
|
|
11
|
+
"""
|
|
12
|
+
assert list(userstory.parse(user_story)) == [
|
|
13
|
+
("", None, None),
|
|
14
|
+
("foo:bar", "foo", "bar"),
|
|
15
|
+
("abcde:", None, None),
|
|
16
|
+
(" foo : baz", "foo", "baz"),
|
|
17
|
+
("some long line: with a colon", None, None),
|
|
18
|
+
("key-:value", "key-", "value"),
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
assert userstory.parse_as_dict(user_story) == {
|
|
22
|
+
"foo": ["bar", "baz"],
|
|
23
|
+
"key-": "value",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def test_update_user_story():
|
|
28
|
+
user_story = """
|
|
29
|
+
foo:bar
|
|
30
|
+
abcde:
|
|
31
|
+
foo : baz
|
|
32
|
+
some long line: with a colon
|
|
33
|
+
key-:value
|
|
34
|
+
key:value
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
changes = [
|
|
38
|
+
userstory.UserStoryChange.replace("foo", "bar", "foobar"),
|
|
39
|
+
userstory.UserStoryChange.delete("key-", "value"),
|
|
40
|
+
userstory.UserStoryChange.append("foo", "new"),
|
|
41
|
+
userstory.UserStoryChange.append("new_key", "new_value"),
|
|
42
|
+
# These ones shouldn't do anything
|
|
43
|
+
userstory.UserStoryChange.replace("foo", "missing", "fail"),
|
|
44
|
+
userstory.UserStoryChange.replace("missing", "missing", "fail"),
|
|
45
|
+
userstory.UserStoryChange.delete("key", "missing"),
|
|
46
|
+
userstory.UserStoryChange.delete("missing", "missing"),
|
|
47
|
+
]
|
|
48
|
+
assert (
|
|
49
|
+
userstory.update(user_story, changes)
|
|
50
|
+
== """
|
|
51
|
+
foo:foobar
|
|
52
|
+
abcde:
|
|
53
|
+
foo:baz
|
|
54
|
+
some long line: with a colon
|
|
55
|
+
key:value
|
|
56
|
+
foo:new
|
|
57
|
+
new_key:new_value
|
|
58
|
+
"""
|
|
59
|
+
)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|