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.
@@ -1,9 +1,9 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bugdantic
3
- Version: 0.2.8
3
+ Version: 0.4.0
4
4
  Summary: Bugzilla client based on pydantic
5
5
  Author-email: James Graham <james@hoppipolla.co.uk>
6
- Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
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 Any, Mapping, MutableMapping, Optional, Sequence, Self
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 bug(
495
- self, bug_id: int, include_fields: Optional[list[str]] = None
496
- ) -> Optional[Bug]:
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
- search_result = BugSearch.model_validate(
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
- query_result = BugsHistory.model_validate(
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 search(
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[Bug]:
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[Bug] = []
638
+ results: list[BugType] = []
562
639
  while True:
563
- response = self.request(
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 = BugSearch.model_validate(response)
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.2.8
3
+ Version: 0.4.0
4
4
  Summary: Bugzilla client based on pydantic
5
5
  Author-email: James Graham <james@hoppipolla.co.uk>
6
- Classifier: License :: OSI Approved :: Mozilla Public License 2.0 (MPL 2.0)
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.2.8"
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 test_serach_include_history(bugzilla):
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