ubq 0.1.0__py3-none-any.whl

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.
ubq/__init__.py ADDED
File without changes
ubq/models/__init__.py ADDED
@@ -0,0 +1,26 @@
1
+ """Generic Ubuntu data models."""
2
+
3
+ from ubq.models.bug import BugRecord, BugTaskRecord
4
+ from ubq.models.common import (
5
+ AuthContext,
6
+ AuthScope,
7
+ CommentRecord,
8
+ ProviderCredentials,
9
+ UserRecord,
10
+ )
11
+ from ubq.models.merge_request import MergeRequestRecord
12
+ from ubq.models.package import PackageRecord
13
+ from ubq.models.version import VersionRecord
14
+
15
+ __all__ = [
16
+ "AuthScope",
17
+ "ProviderCredentials",
18
+ "AuthContext",
19
+ "CommentRecord",
20
+ "UserRecord",
21
+ "BugRecord",
22
+ "BugTaskRecord",
23
+ "VersionRecord",
24
+ "PackageRecord",
25
+ "MergeRequestRecord",
26
+ ]
ubq/models/bug.py ADDED
@@ -0,0 +1,49 @@
1
+ """Bug model records."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+
6
+ from ubq.models.common import CommentRecord, UserRecord
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class BugTaskRecord:
11
+ """Subtask record for a bug."""
12
+
13
+ title: str
14
+ target: str | None = None
15
+ importance: str | None = None
16
+ status: str | None = None
17
+ date_assigned: datetime | None = None
18
+ date_closed: datetime | None = None
19
+ date_created: datetime | None = None
20
+ date_left_closed: datetime | None = None
21
+ date_left_new: datetime | None = None
22
+ date_incomplete: datetime | None = None
23
+ date_confirmed: datetime | None = None
24
+ date_triaged: datetime | None = None
25
+ date_in_progress: datetime | None = None
26
+ date_fix_committed: datetime | None = None
27
+ date_fix_released: datetime | None = None
28
+ milestone: str | None = None
29
+ owner: UserRecord | None = None
30
+ assignee: UserRecord | None = None
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class BugRecord:
35
+ """General bug information record."""
36
+
37
+ provider_name: str
38
+ id: str
39
+ title: str
40
+ description: str | None = None
41
+ tags: list[str] = field(default_factory=list)
42
+ comments: list[CommentRecord] = field(default_factory=list)
43
+ created_at: datetime | None = None
44
+ updated_at: datetime | None = None
45
+ last_message_at: datetime | None = None
46
+ last_patch_at: datetime | None = None
47
+ owner: UserRecord | None = None
48
+ assignee: UserRecord | None = None
49
+ bug_tasks: list[BugTaskRecord] = field(default_factory=list)
ubq/models/common.py ADDED
@@ -0,0 +1,48 @@
1
+ """Shared model records."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+ from enum import Enum
6
+
7
+
8
+ class AuthScope(Enum):
9
+ """Login permission scope Enum."""
10
+
11
+ READ_ONLY = "read-only"
12
+ READ_WRITE = "read-write"
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class UserRecord:
17
+ """Generic user information."""
18
+
19
+ username: str | None = None
20
+ display_name: str | None = None
21
+ profile_url: str | None = None
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class CommentRecord:
26
+ """Comment data for a record."""
27
+
28
+ author: UserRecord | None = None
29
+ content: str = ""
30
+ created_at: datetime | None = None
31
+ edited_at: datetime | None = None
32
+
33
+
34
+ @dataclass(frozen=True, slots=True)
35
+ class ProviderCredentials:
36
+ """Credentials used to authenticate with a provider."""
37
+
38
+ username: str | None = None
39
+ token: str | None = None
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class AuthContext:
44
+ """Authentication context used when creating a provider session."""
45
+
46
+ provider_name: str
47
+ scope: AuthScope
48
+ credentials: ProviderCredentials | None = None
@@ -0,0 +1,27 @@
1
+ """Merge request model records."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+
6
+ from ubq.models.common import UserRecord
7
+ from ubq.models.package import PackageRecord
8
+
9
+
10
+ @dataclass(frozen=True, slots=True)
11
+ class MergeRequestRecord:
12
+ """Merge request or pull request metadata."""
13
+
14
+ provider_name: str
15
+ id: str
16
+ title: str
17
+ description: str
18
+ status: str | None = None
19
+ source_branch: str | None = None
20
+ target_branch: str | None = None
21
+ web_url: str | None = None
22
+ author: UserRecord | None = None
23
+ assignee: UserRecord | None = None
24
+ created_at: datetime | None = None
25
+ updated_at: datetime | None = None
26
+ merged_at: datetime | None = None
27
+ package: PackageRecord | None = None
ubq/models/package.py ADDED
@@ -0,0 +1,11 @@
1
+ """Package model records."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(frozen=True, slots=True)
7
+ class PackageRecord:
8
+ """Package metadata in a provider."""
9
+
10
+ provider_name: str
11
+ name: str
ubq/models/version.py ADDED
@@ -0,0 +1,18 @@
1
+ """Version model records."""
2
+
3
+ from dataclasses import dataclass
4
+ from datetime import datetime
5
+
6
+ from ubq.models.package import PackageRecord
7
+
8
+
9
+ @dataclass(frozen=True, slots=True)
10
+ class VersionRecord:
11
+ """Version information for a package in a provider."""
12
+
13
+ provider_name: str
14
+ version_string: str
15
+ package: PackageRecord | None = None
16
+ pocket: str | None = None
17
+ created_at: datetime | None = None
18
+ released_at: datetime | None = None
@@ -0,0 +1,21 @@
1
+ """Provider interfaces and implementations."""
2
+
3
+ from ubq.providers.bug import BugProvider
4
+ from ubq.providers.launchpad.bug import LaunchpadBugProvider
5
+ from ubq.providers.launchpad.package import LaunchpadPackageProvider
6
+ from ubq.providers.merge_request import MergeRequestProvider
7
+ from ubq.providers.package import PackageProvider
8
+ from ubq.providers.provider import Provider
9
+ from ubq.providers.session import ProviderSession
10
+ from ubq.providers.version import VersionProvider
11
+
12
+ __all__ = [
13
+ "Provider",
14
+ "ProviderSession",
15
+ "BugProvider",
16
+ "PackageProvider",
17
+ "VersionProvider",
18
+ "MergeRequestProvider",
19
+ "LaunchpadBugProvider",
20
+ "LaunchpadPackageProvider",
21
+ ]
ubq/providers/bug.py ADDED
@@ -0,0 +1,17 @@
1
+ """Common interface for bug data providers."""
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from ubq.models import BugRecord
6
+ from ubq.providers.provider import Provider
7
+
8
+
9
+ @runtime_checkable
10
+ class BugProvider(Provider, Protocol):
11
+ """Contract that all bug providers must implement."""
12
+
13
+ def get_bug_metadata(self, bug_id: str) -> BugRecord:
14
+ """Fetch a single bug without additional requests for comments or tasks."""
15
+
16
+ def get_bug(self, bug_id: str) -> BugRecord:
17
+ """Fetch a single bug by provider-specific identifier."""
@@ -0,0 +1,11 @@
1
+ """Launchpad data provider implementations."""
2
+
3
+ from ubq.providers.launchpad.bug import LaunchpadBugProvider
4
+ from ubq.providers.launchpad.package import LaunchpadPackageProvider
5
+ from ubq.providers.launchpad.provider import LaunchpadProvider
6
+
7
+ __all__ = [
8
+ "LaunchpadProvider",
9
+ "LaunchpadBugProvider",
10
+ "LaunchpadPackageProvider",
11
+ ]
@@ -0,0 +1,136 @@
1
+ """Launchpad bug data provider."""
2
+
3
+ from typing import Any
4
+
5
+ from lazr.restfulclient.errors import NotFound
6
+
7
+ from ubq.models import BugRecord, BugTaskRecord, CommentRecord, UserRecord
8
+ from ubq.providers.bug import BugProvider
9
+ from ubq.providers.launchpad.provider import LaunchpadProvider
10
+
11
+ BASE_BUG_URL = "https://api.launchpad.net/devel/ubuntu/+bug/"
12
+ BASE_USER_URL = "https://launchpad.net/~"
13
+
14
+
15
+ class LaunchpadBugProvider(LaunchpadProvider, BugProvider):
16
+ """Provider implementation for Launchpad bugs."""
17
+
18
+ def _fetch_lp_bug_by_id(self, bug_id: str) -> Any:
19
+ """Load a Launchpad URL and attempt to convert it to arbitrary bug data."""
20
+ if self._launchpad is None:
21
+ raise RuntimeError("Launchpad not yet authenticated. Run 'authenticate()' first.")
22
+
23
+ try:
24
+ lp_object = self._launchpad.load(BASE_BUG_URL + bug_id)
25
+ except NotFound:
26
+ return None
27
+
28
+ # Launchpad will sometimes return default bug_task for a bug, if so it needs to be
29
+ # converted to a bug explicitly. Otherwise assume it's already a bug.
30
+ try:
31
+ lp_bug = lp_object.bug
32
+ except AttributeError:
33
+ lp_bug = lp_object
34
+
35
+ return lp_bug
36
+
37
+ def get_bug_task_by_url(self, task_url: str):
38
+ """Fetch a Launchpad bug task by URL."""
39
+ if self._launchpad is None:
40
+ raise RuntimeError("Launchpad not yet authenticated. Run 'authenticate()' first.")
41
+
42
+ try:
43
+ lp_task = self._launchpad.load(task_url)
44
+ except NotFound:
45
+ return None
46
+
47
+ assignee = None
48
+ if hasattr(lp_task, "assignee"):
49
+ assignee = UserRecord(
50
+ username=lp_task.assignee.name,
51
+ display_name=lp_task.assignee.display_name,
52
+ profile_url=f"{BASE_USER_URL}{lp_task.assignee.name}",
53
+ )
54
+
55
+ return BugTaskRecord(
56
+ title=lp_task.title,
57
+ target=lp_task.target,
58
+ importance=lp_task.importance,
59
+ status=lp_task.status,
60
+ date_assigned=lp_task.date_assigned,
61
+ date_closed=lp_task.date_closed,
62
+ date_created=lp_task.date_created,
63
+ date_left_closed=lp_task.date_left_closed,
64
+ date_left_new=lp_task.date_left_new,
65
+ date_incomplete=lp_task.date_incomplete,
66
+ date_confirmed=lp_task.date_confirmed,
67
+ date_triaged=lp_task.date_triaged,
68
+ date_in_progress=lp_task.date_in_progress,
69
+ date_fix_committed=lp_task.date_fix_committed,
70
+ date_fix_released=lp_task.date_fix_released,
71
+ milestone=lp_task.milestone.name if lp_task.milestone else None,
72
+ assignee=assignee,
73
+ )
74
+
75
+ def get_bug_metadata(self, bug_id: str) -> BugRecord:
76
+ """Fetch a Launchpad bug without comments or tasks."""
77
+ lp_bug = self._fetch_lp_bug_by_id(bug_id)
78
+ if lp_bug is None:
79
+ return None
80
+
81
+ return BugRecord(
82
+ provider_name=self.provider_name,
83
+ id=str(lp_bug.id),
84
+ title=lp_bug.title,
85
+ description=lp_bug.description,
86
+ created_at=lp_bug.date_created,
87
+ updated_at=lp_bug.date_last_updated,
88
+ last_message_at=lp_bug.date_last_message,
89
+ last_patch_at=lp_bug.latest_patch_uploaded,
90
+ tags=lp_bug.tags,
91
+ )
92
+
93
+ def get_bug(self, bug_id: str) -> BugRecord:
94
+ """Fetch a Launchpad bug by identifier."""
95
+ lp_bug = self._fetch_lp_bug_by_id(bug_id)
96
+ if lp_bug is None:
97
+ return None
98
+
99
+ tasks = []
100
+ if hasattr(lp_bug, "bug_tasks"):
101
+ tasks = [self.get_bug_task_by_url(str(task)) for task in lp_bug.bug_tasks]
102
+
103
+ comments = []
104
+ if hasattr(lp_bug, "messages"):
105
+ for msg in lp_bug.messages:
106
+ if msg.visible:
107
+ author = None
108
+ if hasattr(msg, "owner") and msg.owner is not None:
109
+ author = UserRecord(
110
+ username=msg.owner.name,
111
+ display_name=msg.owner.display_name,
112
+ profile_url=f"{BASE_USER_URL}{msg.owner.name}",
113
+ )
114
+
115
+ comments.append(
116
+ CommentRecord(
117
+ author=author,
118
+ content=msg.content,
119
+ created_at=msg.date_created,
120
+ edited_at=msg.date_last_edited,
121
+ )
122
+ )
123
+
124
+ return BugRecord(
125
+ provider_name=self.provider_name,
126
+ id=str(lp_bug.id),
127
+ title=lp_bug.title,
128
+ description=lp_bug.description,
129
+ created_at=lp_bug.date_created,
130
+ updated_at=lp_bug.date_last_updated,
131
+ last_message_at=lp_bug.date_last_message,
132
+ last_patch_at=lp_bug.latest_patch_uploaded,
133
+ tags=lp_bug.tags,
134
+ bug_tasks=tasks,
135
+ comments=comments,
136
+ )
@@ -0,0 +1,12 @@
1
+ """Launchpad package data provider."""
2
+
3
+ from ubq.models import PackageRecord
4
+ from ubq.providers.launchpad.provider import LaunchpadProvider
5
+ from ubq.providers.package import PackageProvider
6
+
7
+
8
+ class LaunchpadPackageProvider(LaunchpadProvider, PackageProvider):
9
+ """Provider implementation for Launchpad packages."""
10
+
11
+ def get_package(self, package_name: str) -> PackageRecord:
12
+ """Fetch a Launchpad package by name."""
@@ -0,0 +1,47 @@
1
+ """Shared Launchpad provider base classes."""
2
+
3
+ from launchpadlib.credentials import Credentials
4
+ from launchpadlib.launchpad import Launchpad
5
+
6
+ from ubq.models import AuthContext, AuthScope
7
+ from ubq.providers.session import ProviderSession
8
+
9
+
10
+ class LaunchpadProvider:
11
+ """Common Launchpad provider behavior shared by capability adapters."""
12
+
13
+ provider_name = "launchpad"
14
+
15
+ def __init__(self):
16
+ self._launchpad = None
17
+
18
+ def authenticate(self, auth_context: AuthContext) -> ProviderSession:
19
+ """Authenticate with Launchpad and return a reusable session."""
20
+ if auth_context.credentials is not None and auth_context.credentials.token is not None:
21
+ credentials = Credentials.from_string(auth_context.credentials.token)
22
+
23
+ self._launchpad = Launchpad(
24
+ credentials,
25
+ None,
26
+ None,
27
+ service_root="production",
28
+ version="devel",
29
+ )
30
+
31
+ else:
32
+ access_levels = ["READ_PUBLIC"]
33
+
34
+ if auth_context.scope == AuthScope.READ_WRITE:
35
+ access_levels = ["WRITE_PRIVATE"]
36
+
37
+ self._launchpad = Launchpad.login_with(
38
+ application_name="ubq",
39
+ service_root="production",
40
+ allow_access_levels=access_levels,
41
+ version="devel",
42
+ )
43
+
44
+ return ProviderSession(
45
+ provider_name=self.provider_name,
46
+ scope=auth_context.scope,
47
+ ).with_provider(self)
@@ -0,0 +1,14 @@
1
+ """Common interface for merge request data providers."""
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from ubq.models import MergeRequestRecord
6
+ from ubq.providers.provider import Provider
7
+
8
+
9
+ @runtime_checkable
10
+ class MergeRequestProvider(Provider, Protocol):
11
+ """Contract that all merge request providers must implement."""
12
+
13
+ def get_merge_request(self, merge_request_id: str) -> MergeRequestRecord:
14
+ """Fetch a merge request by provider-specific identifier."""
@@ -0,0 +1,14 @@
1
+ """Common interface for package data providers."""
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from ubq.models import PackageRecord
6
+ from ubq.providers.provider import Provider
7
+
8
+
9
+ @runtime_checkable
10
+ class PackageProvider(Provider, Protocol):
11
+ """Contract that all package providers must implement."""
12
+
13
+ def get_package(self, package_name: str) -> PackageRecord:
14
+ """Fetch a single package by provider-specific name."""
@@ -0,0 +1,16 @@
1
+ """Common interface for all data providers."""
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from ubq.models import AuthContext
6
+ from ubq.providers.session import ProviderSession
7
+
8
+
9
+ @runtime_checkable
10
+ class Provider(Protocol):
11
+ """Base ubq provider class."""
12
+
13
+ provider_name: str
14
+
15
+ def authenticate(self, auth_context: AuthContext) -> "ProviderSession":
16
+ """Authenticate against provider and return a reusable session."""
@@ -0,0 +1,58 @@
1
+ """Common interface for authenticated provider sessions."""
2
+
3
+ from dataclasses import dataclass, replace
4
+ from typing import TYPE_CHECKING
5
+
6
+ from ubq.models import AuthScope
7
+
8
+ if TYPE_CHECKING:
9
+ from ubq.providers import BugProvider, MergeRequestProvider, PackageProvider, VersionProvider
10
+
11
+
12
+ @dataclass(frozen=True, slots=True)
13
+ class ProviderSession:
14
+ """Authenticated provider session scoped by read-only or rw permissions."""
15
+
16
+ provider_name: str
17
+ scope: AuthScope
18
+ bug_provider: "BugProvider | None" = None
19
+ version_provider: "VersionProvider | None" = None
20
+ package_provider: "PackageProvider | None" = None
21
+ merge_request_provider: "MergeRequestProvider | None" = None
22
+
23
+ def with_provider(self, provider: object) -> "ProviderSession":
24
+ """Return a new session with any supported capabilities attached."""
25
+
26
+ from ubq.providers import (
27
+ BugProvider,
28
+ MergeRequestProvider,
29
+ PackageProvider,
30
+ VersionProvider,
31
+ )
32
+
33
+ session = self
34
+ if isinstance(provider, BugProvider):
35
+ session = replace(session, bug_provider=provider)
36
+ if isinstance(provider, VersionProvider):
37
+ session = replace(session, version_provider=provider)
38
+ if isinstance(provider, PackageProvider):
39
+ session = replace(session, package_provider=provider)
40
+ if isinstance(provider, MergeRequestProvider):
41
+ session = replace(session, merge_request_provider=provider)
42
+ return session
43
+
44
+ def get_bug_provider(self) -> "BugProvider | None":
45
+ """Return bug capability for this session if available."""
46
+ return self.bug_provider
47
+
48
+ def get_version_provider(self) -> "VersionProvider | None":
49
+ """Return version capability for this session if available."""
50
+ return self.version_provider
51
+
52
+ def get_package_provider(self) -> "PackageProvider | None":
53
+ """Return package capability for this session if available."""
54
+ return self.package_provider
55
+
56
+ def get_merge_request_provider(self) -> "MergeRequestProvider | None":
57
+ """Return merge request capability for this session if available."""
58
+ return self.merge_request_provider
@@ -0,0 +1,14 @@
1
+ """Common interface for version data providers."""
2
+
3
+ from typing import Protocol, runtime_checkable
4
+
5
+ from ubq.models import VersionRecord
6
+ from ubq.providers.provider import Provider
7
+
8
+
9
+ @runtime_checkable
10
+ class VersionProvider(Provider, Protocol):
11
+ """Contract that all version providers must implement."""
12
+
13
+ def get_version(self, package_name: str, pocket: str) -> VersionRecord:
14
+ """Fetch the version of a package by name and release pocket."""
@@ -0,0 +1,9 @@
1
+ """Service entry points for querying data providers."""
2
+
3
+ from ubq.services.query import QueryService
4
+ from ubq.services.registry import ProviderRegistry
5
+
6
+ __all__ = [
7
+ "QueryService",
8
+ "ProviderRegistry",
9
+ ]
ubq/services/query.py ADDED
@@ -0,0 +1,83 @@
1
+ """Query service for Ubuntu data."""
2
+
3
+ from ubq.models import (
4
+ AuthScope,
5
+ BugRecord,
6
+ MergeRequestRecord,
7
+ PackageRecord,
8
+ ProviderCredentials,
9
+ VersionRecord,
10
+ )
11
+ from ubq.services.registry import ProviderRegistry
12
+
13
+
14
+ class QueryService:
15
+ """Query service for data from provider sessions."""
16
+
17
+ def __init__(self, registry: ProviderRegistry | None = None):
18
+ self._registry = registry or ProviderRegistry()
19
+
20
+ def login(
21
+ self,
22
+ provider_name: str,
23
+ scope: AuthScope = AuthScope.READ_ONLY,
24
+ credentials: ProviderCredentials | None = None,
25
+ force: bool = False,
26
+ ) -> None:
27
+ """Create or refresh a scoped provider session."""
28
+ self._registry.login(
29
+ provider_name=provider_name,
30
+ scope=scope,
31
+ credentials=credentials,
32
+ force=force,
33
+ )
34
+
35
+ def get_bug(
36
+ self,
37
+ bug_id: str,
38
+ provider_name: str,
39
+ scope: AuthScope = AuthScope.READ_ONLY,
40
+ metadata_only: bool = False,
41
+ ) -> BugRecord:
42
+ """Fetch a bug from a provider using an active scoped session."""
43
+ provider = self._registry.get_bug_provider(provider_name, scope=scope)
44
+
45
+ if metadata_only:
46
+ return provider.get_bug_metadata(bug_id)
47
+
48
+ return provider.get_bug(bug_id)
49
+
50
+ def get_version(
51
+ self,
52
+ package_name: str,
53
+ pocket: str,
54
+ provider_name: str,
55
+ scope: AuthScope = AuthScope.READ_ONLY,
56
+ ) -> VersionRecord:
57
+ """Fetch package version metadata using an active scoped session."""
58
+ provider = self._registry.get_version_provider(provider_name, scope=scope)
59
+ return provider.get_version(package_name, pocket)
60
+
61
+ def get_package(
62
+ self,
63
+ package_name: str,
64
+ provider_name: str,
65
+ scope: AuthScope = AuthScope.READ_ONLY,
66
+ ) -> PackageRecord:
67
+ """Fetch a package from a provider using an active scoped session."""
68
+ provider = self._registry.get_package_provider(provider_name, scope=scope)
69
+ return provider.get_package(package_name)
70
+
71
+ def get_merge_request(
72
+ self,
73
+ merge_request_id: str,
74
+ provider_name: str,
75
+ scope: AuthScope = AuthScope.READ_ONLY,
76
+ ) -> MergeRequestRecord:
77
+ """Fetch a merge request using an active scoped session."""
78
+ provider = self._registry.get_merge_request_provider(provider_name, scope=scope)
79
+ return provider.get_merge_request(merge_request_id)
80
+
81
+ def available_providers(self) -> tuple[str, ...]:
82
+ """Return all provider names queryable by this service."""
83
+ return self._registry.available_provider_names()