bugdantic 0.3.0__tar.gz → 0.4.1__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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bugdantic
3
- Version: 0.3.0
3
+ Version: 0.4.1
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
@@ -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,27 @@ 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
+
457
+ def model_field_names(model: type[BaseModel]) -> list[str]:
458
+ return [
459
+ field.alias if field.alias else name
460
+ for name, field in model.model_fields.items()
461
+ ]
462
+
463
+
425
464
  class Bugzilla:
426
465
  def __init__(self, config: BugzillaConfig):
427
466
  self.config = config
@@ -491,13 +530,27 @@ class Bugzilla:
491
530
  """)
492
531
  return {}
493
532
 
494
- def bug(
495
- self, bug_id: int, include_fields: Optional[list[str]] = None
496
- ) -> Optional[Bug]:
533
+ def check_error(self, data: Mapping[str, Json]) -> Mapping[str, Json]:
534
+ try:
535
+ err = ErrorResponse.model_validate(data)
536
+ if err.error:
537
+ raise BugzillaError(err.message)
538
+ except pydantic.ValidationError:
539
+ pass
540
+ return data
541
+
542
+ def _bug(
543
+ self,
544
+ bug_id: int,
545
+ bug_type: type[BugType],
546
+ include_fields: Optional[list[str]] = None,
547
+ ) -> Optional[BugType]:
497
548
  """Get a single bug specified by id"""
498
- search_result = BugSearch.model_validate(
549
+
550
+ data = self.check_error(
499
551
  self.request("GET", f"bug/{bug_id}", include_fields=include_fields)
500
552
  )
553
+ search_result = bug_search_model(bug_type).model_validate(data)
501
554
  if search_result.faults:
502
555
  raise BugzillaError(search_result.faults)
503
556
  bugs = search_result.bugs
@@ -506,6 +559,15 @@ class Bugzilla:
506
559
  assert len(bugs) == 1
507
560
  return bugs[0]
508
561
 
562
+ def bug(
563
+ self, bug_id: int, include_fields: Optional[list[str]] = None
564
+ ) -> Optional[Bug]:
565
+ return self._bug(bug_id, Bug, include_fields)
566
+
567
+ def bug_as(self, bug_id: int, bug_type: type[BugType]) -> Optional[BugType]:
568
+ include_fields = model_field_names(bug_type)
569
+ return self._bug(bug_id, bug_type, include_fields)
570
+
509
571
  def bugs(
510
572
  self,
511
573
  bug_ids: Sequence[int],
@@ -525,6 +587,25 @@ class Bugzilla:
525
587
  )
526
588
  return results
527
589
 
590
+ def bugs_as(
591
+ self,
592
+ bug_ids: Sequence[int],
593
+ bug_type: type[BugType],
594
+ page_size: int = 100,
595
+ ) -> list[Bug]:
596
+ """Get multiple bugs specified by id"""
597
+ results: list[Bug] = []
598
+ for bug_ids_chunk in [
599
+ bug_ids[n : n + page_size] for n in range(0, len(bug_ids), page_size)
600
+ ]:
601
+ results.extend(
602
+ self.search_as(
603
+ {"id": ",".join(str(id) for id in bug_ids_chunk)},
604
+ bug_type,
605
+ )
606
+ )
607
+ return results
608
+
528
609
  def bug_history(
529
610
  self, bug_id: int, new_since: Optional[datetime] = None
530
611
  ) -> BugHistory:
@@ -532,9 +613,10 @@ class Bugzilla:
532
613
  params = {}
533
614
  if new_since is not None:
534
615
  params["new_since"] = new_since.strftime("%Y-%m-%dT%H:%M:%SZ")
535
- query_result = BugsHistory.model_validate(
616
+ data = self.check_error(
536
617
  self.request("GET", f"bug/{bug_id}/history", params=params)
537
618
  )
619
+ query_result = BugsHistory.model_validate(data)
538
620
  if query_result.faults:
539
621
  raise BugzillaError(query_result.faults)
540
622
  bugs = query_result.bugs
@@ -543,13 +625,15 @@ class Bugzilla:
543
625
  assert len(bugs) == 1
544
626
  return bugs[0]
545
627
 
546
- def search(
628
+ def _search(
547
629
  self,
630
+ bug_type: type[BugType],
548
631
  query: QueryParams,
549
632
  include_fields: Optional[list[str]] = None,
550
633
  page_size: int = 100,
551
- ) -> list[Bug]:
634
+ ) -> list[BugType]:
552
635
  """Search for bugs using the bugzilla query API"""
636
+
553
637
  query = {**query}
554
638
  paginate = False
555
639
  offset = 0
@@ -558,12 +642,12 @@ class Bugzilla:
558
642
  query["offset"] = "0"
559
643
  paginate = True
560
644
 
561
- results: list[Bug] = []
645
+ results: list[BugType] = []
562
646
  while True:
563
- response = self.request(
564
- "GET", "bug", params=query, include_fields=include_fields
647
+ response = self.check_error(
648
+ self.request("GET", "bug", params=query, include_fields=include_fields)
565
649
  )
566
- search_result = BugSearch.model_validate(response)
650
+ search_result = bug_search_model(bug_type).model_validate(response)
567
651
  if search_result.faults:
568
652
  raise BugzillaError(search_result.faults)
569
653
  if search_result.bugs is not None:
@@ -581,6 +665,20 @@ class Bugzilla:
581
665
 
582
666
  return results
583
667
 
668
+ def search(
669
+ self,
670
+ query: QueryParams,
671
+ include_fields: Optional[list[str]] = None,
672
+ page_size: int = 100,
673
+ ) -> list[Bug]:
674
+ return self._search(Bug, query, include_fields, page_size)
675
+
676
+ def search_as(
677
+ self, query: QueryParams, bug_type: type[BugType], page_size: int = 100
678
+ ):
679
+ include_fields = model_field_names(bug_type)
680
+ return self._search(bug_type, query, include_fields, page_size)
681
+
584
682
  def update_bugs(
585
683
  self, update_params: BugUpdate, bug_id: Optional[int] = None
586
684
  ) -> list[BugUpdateResponse]:
@@ -600,7 +698,7 @@ class Bugzilla:
600
698
 
601
699
  json_body = update_params.model_dump(exclude_none=True)
602
700
 
603
- response = self.request("PUT", path, json_body=json_body)
701
+ response = self.check_error(self.request("PUT", path, json_body=json_body))
604
702
 
605
703
  if self.config.allow_writes:
606
704
  update_result = BugsUpdateResponse.model_validate(response)
@@ -632,7 +730,7 @@ class Bugzilla:
632
730
  if not json_body.get("ids"):
633
731
  json_body["ids"] = [bug_id]
634
732
 
635
- response = self.request("POST", path, json_body=json_body)
733
+ response = self.check_error(self.request("POST", path, json_body=json_body))
636
734
 
637
735
  if self.config.allow_writes:
638
736
  create_result = AttachmentsCreateResponse.model_validate(response)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bugdantic
3
- Version: 0.3.0
3
+ Version: 0.4.1
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
@@ -5,7 +5,7 @@ authors = [
5
5
  {name = "James Graham", email = "james@hoppipolla.co.uk"}
6
6
  ]
7
7
 
8
- version = "0.3.0"
8
+ version = "0.4.1"
9
9
  requires-python = ">=3.11"
10
10
  license = "MPL-2.0"
11
11
  classifiers = [
@@ -1,6 +1,8 @@
1
1
  import pytest
2
+ from pydantic import BaseModel, Field
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,38 @@ 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)
72
+
73
+
74
+ def test_bug_as_alias(bugzilla):
75
+ class BugData(BaseModel):
76
+ id: int
77
+ user_story: str = Field(alias="cf_user_story")
78
+
79
+ result = bugzilla.bug_as(975444, BugData)
80
+ assert isinstance(result, BugData)
81
+ assert result.id == 975444
82
+ assert result.user_story is not None
File without changes
File without changes
File without changes
File without changes