bugdantic 0.2.8__tar.gz → 0.3.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.3.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
@@ -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.3.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.3.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
  ]
@@ -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