Skip to content

Commit c272dff

Browse files
authored
Refactor LifecycleFlag to help editors (#54)
Dynamically created class didn't work well with VSCode and attribute access. * Use a real class * Remove `BaseLifecycleFlag`. It's not needed anymore * Use `auto()` to get integer value * Use only "unknown" in lowercase to avoid problems with iterating over the class
1 parent 529eb0b commit c272dff

6 files changed

Lines changed: 58 additions & 33 deletions

File tree

changelog.d/53.refactor.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Refactor :class:`~docbuild.models.lifecycle.LifecycleFlag`.
2+
Dynamically created class didn't work well with VSCode and
3+
attribute access.

src/docbuild/config/xml/list.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ def list_all_deliverables(
3939
if '*' not in dt.docset:
4040
xpath += '[' + ' or '.join([f'@setid={d!r}' for d in dt.docset]) + ']'
4141

42-
if LifecycleFlag.UNKNOWN != dt.lifecycle: # type: ignore
42+
if LifecycleFlag.unknown != dt.lifecycle: # type: ignore
4343
xpath += (
4444
'['
4545
+ ' or '.join([f'@lifecycle={lc.name!r}' for lc in dt.lifecycle])

src/docbuild/constants.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from pathlib import Path
44
import re
55

6+
from .models.lifecycle import LifecycleFlag
67
from .models.serverroles import ServerRole
78

89
APP_NAME = 'docbuild'
@@ -24,14 +25,20 @@
2425
# "testing", "test", "t",
2526
# "staging", "stage", "s",
2627
# )
27-
SERVER_ROLES = tuple([role.value for role in ServerRole])
28+
SERVER_ROLES = tuple(
29+
[role.value for role in ServerRole] # type: ignore[call-arg]
30+
)
2831
"""The different server roles, including long and short spelling."""
2932

3033
DEFAULT_LIFECYCLE = 'supported'
3134
"""The default lifecycle state for a docset."""
3235

33-
ALLOWED_LIFECYCLES = ('supported', 'beta', 'hidden', 'unsupported')
34-
"""The available lifecycle states for a docset."""
36+
ALLOWED_LIFECYCLES: tuple[str] = tuple(
37+
lc.name
38+
for lc in LifecycleFlag
39+
)
40+
# ('supported', 'beta', 'hidden', 'unsupported')
41+
"""The available lifecycle states for a docset (without 'unknown')."""
3542

3643

3744
# All product acronyms and their names
@@ -141,7 +148,7 @@
141148
# --- State and Logging Constants (Refactored) ---
142149

143150
BASE_STATE_DIR = Path.home() / '.local' / 'state' / APP_NAME
144-
"""The directory where application state, logs, and locks are stored,
151+
"""The directory where application state, logs, and locks are stored,
145152
per XDG Base Directory Specification."""
146153

147154
GITLOGGER_NAME = "docbuild.git"

src/docbuild/models/doctype.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from pydantic import BaseModel, Field, field_validator
88

99
from .language import LanguageCode
10-
from .lifecycle import BaseLifecycleFlag, LifecycleFlag
10+
from .lifecycle import LifecycleFlag
1111
from .product import Product
1212

1313

@@ -185,7 +185,7 @@ def coerce_docset(cls, value: str | list[str]) -> list[str]:
185185

186186
@field_validator('lifecycle', mode='before')
187187
@classmethod
188-
def coerce_lifecycle(cls, value: str | LifecycleFlag) -> BaseLifecycleFlag:
188+
def coerce_lifecycle(cls, value: str | LifecycleFlag) -> LifecycleFlag:
189189
"""Convert a string into a LifecycleFlag."""
190190
# value = "" if value is None else value
191191
if isinstance(value, str):
@@ -261,7 +261,7 @@ def xpath(self) -> str:
261261
[
262262
f'@lifecycle={lc.name!r}'
263263
for lc in self.lifecycle
264-
if lc != LifecycleFlag.UNKNOWN
264+
if lc != LifecycleFlag.unknown
265265
]
266266
)
267267
if lifecycle:

src/docbuild/models/lifecycle.py

Lines changed: 39 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,44 +1,67 @@
11
"""Lifecycle model for docbuild."""
22

3-
from enum import Flag
3+
from enum import Flag, auto
44
import re
5+
from typing import ClassVar, Self
56

6-
from ..constants import ALLOWED_LIFECYCLES
77

8-
_SEPARATOR = re.compile(r'[|,]')
8+
class LifecycleFlag(Flag):
9+
"""LifecycleFlag represents the lifecycle of a product."""
910

11+
# Order is important here
12+
unknown = 0
13+
# UNKNOWN = 0
14+
"""Unknown lifecycle state."""
1015

11-
class BaseLifecycleFlag(Flag):
12-
"""Base class for LifecycleFlag."""
16+
supported = auto()
17+
"""Supported lifecycle state."""
18+
19+
beta = auto()
20+
"""Beta lifecycle state."""
21+
22+
hidden = auto()
23+
"""Hidden lifecycle state."""
24+
25+
unsupported = auto()
26+
"""Unsupported lifecycle state."""
27+
28+
# NOTE: Putting a compiled regex (or other helper) as a class
29+
# variable on an Enum/Flag is error-prone:
30+
# the Enum metaclass treats class attributes specially and may
31+
# convert them into members or otherwise interfere.
32+
#
33+
# Solution: This class variable will be attached after class creation.
34+
# _SEPARATOR = re.compile(r'[|,]') # Static class variable
1335

1436
@classmethod
15-
def from_str(cls, value: str) -> 'BaseLifecycleFlag':
37+
def from_str(cls: 'LifecycleFlag', value: str) -> 'LifecycleFlag':
1638
"""Convert a string to a LifecycleFlag object.
1739
1840
The string accepts the values 'supported', 'beta', 'hidden',
1941
'unsupported', or a combination of them separated by a comma or pipe.
20-
Addtionally, the class knows the values "UNKNOWN" and "unknown".
21-
An empty string, "", is equivalent to "UNKNOWN".
42+
Addtionally, the class knows the values "unknown".
43+
An empty string, "", is equivalent to "unknown".
2244
2345
Examples:
2446
>>> LifecycleFlag.from_str("supported")
2547
<LifecycleFlag.supported: 2>
26-
>>> LifecycleFlag.from_str("supported|beta")
48+
>>> LifecycleFlag.from_str("supported,beta")
2749
<LifecycleFlag.supported|beta: 6>
2850
>>> LifecycleFlag.from_str("beta,supported|beta")
2951
<LifecycleFlag.supported|beta: 6>
3052
>>> LifecycleFlag.from_str("")
3153
<LifecycleFlag.unknown: 0>
3254
3355
"""
56+
separator = cls._SEPARATOR # will exist after we attach it
3457
try:
3558
flag = cls(0) # Start with an empty flag
36-
parts = [v.strip() for v in _SEPARATOR.split(value) if v.strip()]
59+
parts = [v.strip() for v in separator.split(value) if v.strip()]
3760
if not parts:
3861
return cls(0)
3962

4063
for part_name in parts:
41-
flag |= cls[part_name]
64+
flag |= cls.__members__[part_name]
4265

4366
return flag
4467

@@ -48,12 +71,12 @@ def from_str(cls, value: str) -> 'BaseLifecycleFlag':
4871
f'Invalid lifecycle name: {err.args[0]!r}. Allowed values: {allowed}',
4972
) from err
5073

51-
def __contains__(self, other: str | Flag) -> bool:
74+
def __contains__(self: Self, other: str | Flag) -> bool:
5275
"""Return True if self has at least one of same flags set as other.
5376
54-
>>> "supported" in Lifecycle.beta
77+
>>> "supported" in LifecycleFlag.beta
5578
False
56-
>>> "supported|beta" in Lifecycle.beta
79+
>>> "supported|beta" in LifecycleFlag.beta
5780
True
5881
"""
5982
if isinstance(other, str):
@@ -68,13 +91,5 @@ def __contains__(self, other: str | Flag) -> bool:
6891
return (self & item_flag) == item_flag
6992

7093

71-
# Lifecycle is implemented as a Flag as different states can be combined
72-
# An additional "unknown" state could be used if the state is unknown or not yet
73-
# retrieved.
74-
# TODO: Should we allow weird combination like "supported|unsupported"
75-
LifecycleFlag = BaseLifecycleFlag(
76-
'LifecycleFlag',
77-
{'unknown': 0, 'UNKNOWN': 0}
78-
| {item: (2 << index) for index, item in enumerate(ALLOWED_LIFECYCLES, 0)},
79-
)
80-
"""LifecycleFlag represents the lifecycle of a product."""
94+
# attach after class creation so EnumMeta doesn't touch it
95+
LifecycleFlag._SEPARATOR: ClassVar[re.Pattern] = re.compile(r'[|,]')

tests/models/test_lifecycle.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ def test_unknown_lifecycle():
2222

2323
def test_lifecycle_flag_from_str_with_empty_string():
2424
instance = LifecycleFlag.from_str('')
25-
assert instance == LifecycleFlag.UNKNOWN
25+
assert instance == LifecycleFlag.unknown
2626
assert instance.name == 'unknown'
2727
assert instance.value == 0
2828

0 commit comments

Comments
 (0)