Enrique Sánchez has proposed merging ~enriqueesanchz/launchpad:add-external-package into launchpad:master.
Commit message: Add ExternalPackage model ExternalPackage is a valid target to report bugs against it. Requested reviews: Launchpad code reviewers (launchpad-reviewers) For more details, see: https://code.launchpad.net/~enriqueesanchz/launchpad/+git/launchpad/+merge/488673 -- Your team Launchpad code reviewers is requested to review the proposed merge of ~enriqueesanchz/launchpad:add-external-package into launchpad:master.
diff --git a/lib/lp/app/browser/tales.py b/lib/lp/app/browser/tales.py index 1714f52..da41350 100644 --- a/lib/lp/app/browser/tales.py +++ b/lib/lp/app/browser/tales.py @@ -52,6 +52,7 @@ from lp.registry.interfaces.distribution import IDistribution from lp.registry.interfaces.distributionsourcepackage import ( IDistributionSourcePackage, ) +from lp.registry.interfaces.externalpackage import IExternalPackage from lp.registry.interfaces.person import IPerson from lp.registry.interfaces.product import IProduct from lp.registry.interfaces.projectgroup import IProjectGroup @@ -733,6 +734,9 @@ class ObjectImageDisplayAPI: sprite_string = "distribution" elif IDistributionSourcePackage.providedBy(context): sprite_string = "package-source" + elif IExternalPackage.providedBy(context): + # TODO: create a new sprite for ExternalPackages? + sprite_string = "package-source" elif ISprint.providedBy(context): sprite_string = "meeting" elif IBug.providedBy(context): diff --git a/lib/lp/app/widgets/launchpadtarget.py b/lib/lp/app/widgets/launchpadtarget.py index d296589..ff3dae1 100644 --- a/lib/lp/app/widgets/launchpadtarget.py +++ b/lib/lp/app/widgets/launchpadtarget.py @@ -28,6 +28,7 @@ from lp.registry.interfaces.distribution import IDistribution from lp.registry.interfaces.distributionsourcepackage import ( IDistributionSourcePackage, ) +from lp.registry.interfaces.externalpackage import IExternalPackage from lp.registry.interfaces.product import IProduct from lp.services.features import getFeatureFlag from lp.services.webapp.interfaces import ( @@ -218,6 +219,10 @@ class LaunchpadTargetWidget(BrowserWidget, InputWidget): self.default_option = "package" self.distribution_widget.setRenderedValue(value.distribution) self.package_widget.setRenderedValue(value.sourcepackagename) + elif IExternalPackage.providedBy(value): + self.default_option = "package" + self.distribution_widget.setRenderedValue(value.distribution) + self.package_widget.setRenderedValue(value.sourcepackagename) else: raise AssertionError("Not a valid value: %r" % value) diff --git a/lib/lp/app/widgets/tests/test_launchpadtarget.py b/lib/lp/app/widgets/tests/test_launchpadtarget.py index 40350b8..16f5f3e 100644 --- a/lib/lp/app/widgets/tests/test_launchpadtarget.py +++ b/lib/lp/app/widgets/tests/test_launchpadtarget.py @@ -61,6 +61,9 @@ class LaunchpadTargetWidgetTestCase(TestCaseWithFactory): self.package = self.factory.makeDSPCache( distroseries=distroseries, sourcepackagename="snarf" ) + self.externalpackage = self.factory.makeExternalPackage( + distribution=self.distribution, sourcepackagename="snarf" + ) self.project = self.factory.makeProduct("pting") field = Reference(__name__="target", schema=Interface, title="target") field = field.bind(Thing()) @@ -313,6 +316,21 @@ class LaunchpadTargetWidgetTestCase(TestCaseWithFactory): self.widget.package_widget._getCurrentValue(), ) + def test_setRenderedValue_externalpackage(self): + # Passing an external package will set the widget's render state to + # 'externalpackage'. + self.widget.setUpSubWidgets() + self.widget.setRenderedValue(self.externalpackage) + self.assertEqual("package", self.widget.default_option) + self.assertEqual( + self.distribution, + self.widget.distribution_widget._getCurrentValue(), + ) + self.assertEqual( + self.externalpackage.sourcepackagename, + self.widget.package_widget._getCurrentValue(), + ) + def test_call(self): # The __call__ method setups the widgets and the options. markup = self.widget() diff --git a/lib/lp/bugs/browser/bugtask.py b/lib/lp/bugs/browser/bugtask.py index 8fcb135..69f7f0c 100644 --- a/lib/lp/bugs/browser/bugtask.py +++ b/lib/lp/bugs/browser/bugtask.py @@ -126,6 +126,7 @@ from lp.registry.interfaces.distributionsourcepackage import ( IDistributionSourcePackage, ) from lp.registry.interfaces.distroseries import IDistroSeries, IDistroSeriesSet +from lp.registry.interfaces.externalpackage import IExternalPackage from lp.registry.interfaces.ociproject import IOCIProject from lp.registry.interfaces.person import IPersonSet from lp.registry.interfaces.product import IProduct @@ -320,7 +321,12 @@ class BugTargetTraversalMixin: # rather than making it look as though this task was "not found", # because it was filtered out by privacy-aware code. for bugtask in bug.bugtasks: - if bugtask.target == context: + if bugtask.target == context or IExternalPackage.providedBy( + bugtask.target + ): + # TODO: set +external urls for ExternalPackages + # actually we select the first ExternalPackage that appears + # Security proxy this object on the way out. return getUtility(IBugTaskSet).get(bugtask.id) @@ -1820,6 +1826,16 @@ def bugtask_sort_key(bugtask): None, None, ) + elif IExternalPackage.providedBy(bugtask.target): + key = ( + bugtask.target.sourcepackagename.name, + bugtask.target.distribution.displayname, + bugtask.target.packagetype, + bugtask.target.channel, + None, + None, + None, + ) elif ISourcePackage.providedBy(bugtask.target): key = ( bugtask.target.sourcepackagename.name, diff --git a/lib/lp/bugs/browser/configure.zcml b/lib/lp/bugs/browser/configure.zcml index beeef02..3b1c7d8 100644 --- a/lib/lp/bugs/browser/configure.zcml +++ b/lib/lp/bugs/browser/configure.zcml @@ -1180,6 +1180,10 @@ layer="lp.bugs.publisher.BugsLayer" name="+bugs"/> <browser:defaultView + for="lp.registry.interfaces.externalpackage.IExternalPackage" + layer="lp.bugs.publisher.BugsLayer" + name="+bugs"/> + <browser:defaultView for="lp.registry.interfaces.person.IPerson" layer="lp.bugs.publisher.BugsLayer" name="+bugs"/> diff --git a/lib/lp/bugs/browser/structuralsubscription.py b/lib/lp/bugs/browser/structuralsubscription.py index ec278f7..96f2687 100644 --- a/lib/lp/bugs/browser/structuralsubscription.py +++ b/lib/lp/bugs/browser/structuralsubscription.py @@ -35,6 +35,7 @@ from lp.registry.interfaces.distribution import IDistribution from lp.registry.interfaces.distributionsourcepackage import ( IDistributionSourcePackage, ) +from lp.registry.interfaces.externalpackage import IExternalPackage from lp.registry.interfaces.milestone import IProjectGroupMilestone from lp.registry.interfaces.person import IPerson, IPersonSet from lp.services.propertycache import cachedproperty @@ -284,9 +285,11 @@ class StructuralSubscriptionView(LaunchpadFormView): def userIsDriver(self): """Has the current user driver permissions?""" # We only want to look at this if the target is a - # distribution source package, in order to maintain + # distribution or external package, in order to maintain # compatibility with the obsolete bug contacts feature. - if IDistributionSourcePackage.providedBy(self.context): + if IDistributionSourcePackage.providedBy( + self.context + ) or IExternalPackage.providedBy(self.context): return check_permission( "launchpad.Driver", self.context.distribution ) diff --git a/lib/lp/bugs/browser/tests/test_bugtask.py b/lib/lp/bugs/browser/tests/test_bugtask.py index 8d665f0..9e23fb2 100644 --- a/lib/lp/bugs/browser/tests/test_bugtask.py +++ b/lib/lp/bugs/browser/tests/test_bugtask.py @@ -873,6 +873,7 @@ class TestBugTasksTableView(TestCaseWithFactory): foo_ociproject = self.factory.makeOCIProject(pillar=foo) barix_ociproject = self.factory.makeOCIProject(pillar=barix) + # TODO: test when +bugtasks-and-nominations-table implemented expected_targets = [ bar, bar.getSeries("0.0"), diff --git a/lib/lp/bugs/interfaces/bugtask.py b/lib/lp/bugs/interfaces/bugtask.py index e22d8d0..9d8a36c 100644 --- a/lib/lp/bugs/interfaces/bugtask.py +++ b/lib/lp/bugs/interfaces/bugtask.py @@ -478,6 +478,17 @@ class IBugTask(IHasBug, IBugTaskDelete): title=_("Package"), required=False, vocabulary="SourcePackageName" ) sourcepackagename_id = Attribute("The sourcepackagename ID") + + packagetype = Int( + title=_("Package type"), + default=None, + readonly=True, + ) + + channel = Attribute("The package channel") + + metadata = Attribute("Bugtask metadata") + distribution = Choice( title=_("Distribution"), required=False, vocabulary="Distribution" ) diff --git a/lib/lp/bugs/model/bugtask.py b/lib/lp/bugs/model/bugtask.py index d6116ce..5e9af48 100644 --- a/lib/lp/bugs/model/bugtask.py +++ b/lib/lp/bugs/model/bugtask.py @@ -23,6 +23,7 @@ from itertools import chain, repeat from operator import attrgetter, itemgetter from lazr.lifecycle.event import ObjectDeletedEvent +from storm.databases.postgres import JSON from storm.expr import ( SQL, And, @@ -78,6 +79,10 @@ from lp.registry.interfaces.distributionsourcepackage import ( IDistributionSourcePackage, ) from lp.registry.interfaces.distroseries import IDistroSeries +from lp.registry.interfaces.externalpackage import ( + ExternalPackageType, + IExternalPackage, +) from lp.registry.interfaces.milestone import IMilestoneSet from lp.registry.interfaces.milestonetag import IProjectGroupMilestoneTag from lp.registry.interfaces.ociproject import IOCIProject @@ -170,6 +175,8 @@ def bug_target_from_key( distroseries, sourcepackagename, ociproject, + packagetype, + channel, ): """Returns the IBugTarget defined by the given DB column values.""" if ociproject: @@ -182,7 +189,11 @@ def bug_target_from_key( elif productseries: return productseries elif distribution: - if sourcepackagename: + if sourcepackagename and packagetype: + return distribution.getExternalPackage( + sourcepackagename, packagetype, removeSecurityProxy(channel) + ) + elif sourcepackagename: return distribution.getSourcePackage(sourcepackagename) else: return distribution @@ -204,6 +215,8 @@ def bug_target_to_key(target): distroseries=None, sourcepackagename=None, ociproject=None, + packagetype=None, + channel=None, ) if IProduct.providedBy(target): values["product"] = target @@ -219,6 +232,11 @@ def bug_target_to_key(target): elif ISourcePackage.providedBy(target): values["distroseries"] = target.distroseries values["sourcepackagename"] = target.sourcepackagename + elif IExternalPackage.providedBy(target): + values["distribution"] = target.distribution + values["sourcepackagename"] = target.sourcepackagename + values["packagetype"] = target.packagetype + values["channel"] = removeSecurityProxy(target).channel elif IOCIProject.providedBy(target): # De-normalize the ociproject, including also the ociproject's # pillar (distribution or product). @@ -371,6 +389,9 @@ def validate_target( ) except NotFoundError as e: raise IllegalTarget(e.args[0]) + elif IExternalPackage.providedBy(target): + # TODO: Check with store/soss that package exists + pass legal_types = target.pillar.getAllowedBugInformationTypes() new_pillar = target.pillar not in bug.affected_pillars @@ -422,7 +443,9 @@ def validate_new_target(bug, target, check_source_package=True): "affected package in which the bug has not yet " "been reported." % target.displayname ) - elif IDistributionSourcePackage.providedBy(target): + elif IDistributionSourcePackage.providedBy( + target + ) or IExternalPackage.providedBy(target): # Ensure that there isn't already a generic task open on the # distribution for this bug, because if there were, that task # should be reassigned to the sourcepackage, rather than a new @@ -493,6 +516,17 @@ class BugTask(StormBase): sourcepackagename_id = Int(name="sourcepackagename", allow_none=True) sourcepackagename = Reference(sourcepackagename_id, "SourcePackageName.id") + packagetype = DBEnum( + name="packagetype", + allow_none=True, + enum=ExternalPackageType, + default=None, + ) + + channel = JSON(name="channel", allow_none=True, default=None) + + metadata = JSON(name="metadata", allow_none=True, default=None) + distribution_id = Int(name="distribution", allow_none=True) distribution = Reference(distribution_id, "Distribution.id") @@ -627,6 +661,17 @@ class BugTask(StormBase): return self._status @property + def display_channel(self): + if self.channel is None: + return None + + channel_list = [self.channel.get("track"), self.channel.get("risk")] + if branch := self.channel.get("branch", "") != "": + channel_list.append(branch) + + return "/".join(channel_list) + + @property def title(self): """See `IBugTask`.""" return 'Bug #%s in %s: "%s"' % ( @@ -660,6 +705,8 @@ class BugTask(StormBase): self.distroseries, self.sourcepackagename, self.ociproject, + self.packagetype, + self.channel, ) @property @@ -1883,6 +1930,8 @@ class BugTaskSet: key["distribution"], key["distroseries"], key["sourcepackagename"], + key["packagetype"], + key["channel"], key["ociproject"], status, importance, @@ -1900,6 +1949,8 @@ class BugTaskSet: BugTask.distribution, BugTask.distroseries, BugTask.sourcepackagename, + BugTask.packagetype, + BugTask.channel, BugTask.ociproject, BugTask._status, BugTask.importance, diff --git a/lib/lp/bugs/model/structuralsubscription.py b/lib/lp/bugs/model/structuralsubscription.py index 37e8be2..debb06e 100644 --- a/lib/lp/bugs/model/structuralsubscription.py +++ b/lib/lp/bugs/model/structuralsubscription.py @@ -58,6 +58,7 @@ from lp.registry.interfaces.distributionsourcepackage import ( IDistributionSourcePackage, ) from lp.registry.interfaces.distroseries import IDistroSeries +from lp.registry.interfaces.externalpackage import IExternalPackage from lp.registry.interfaces.milestone import IMilestone from lp.registry.interfaces.ociproject import IOCIProject from lp.registry.interfaces.person import ( @@ -607,7 +608,12 @@ def get_structural_subscriptions_for_bug(bug, person=None): # This is here because of a circular import. from lp.registry.model.person import Person - bugtasks = bug.bugtasks + bugtasks = [] + # TODO: support bug subscriptions + for bugtask in bug.bugtasks: + if not IExternalPackage.providedBy(bugtask.target): + bugtasks.append(bugtask) + if not bugtasks: return EmptyResultSet() conditions = [] diff --git a/lib/lp/bugs/model/tests/test_bugtask.py b/lib/lp/bugs/model/tests/test_bugtask.py index 4549f0a..09e704f 100644 --- a/lib/lp/bugs/model/tests/test_bugtask.py +++ b/lib/lp/bugs/model/tests/test_bugtask.py @@ -3174,6 +3174,8 @@ class TestBugTargetKeys(TestCaseWithFactory): distroseries=None, sourcepackagename=None, ociproject=None, + packagetype=None, + channel=None, ), ) @@ -3187,6 +3189,8 @@ class TestBugTargetKeys(TestCaseWithFactory): distribution=None, distroseries=None, sourcepackagename=None, + packagetype=None, + channel=None, ociproject=None, ), ) @@ -3201,6 +3205,8 @@ class TestBugTargetKeys(TestCaseWithFactory): distribution=distro, distroseries=None, sourcepackagename=None, + packagetype=None, + channel=None, ociproject=None, ), ) @@ -3215,6 +3221,8 @@ class TestBugTargetKeys(TestCaseWithFactory): distribution=None, distroseries=distroseries, sourcepackagename=None, + packagetype=None, + channel=None, ociproject=None, ), ) @@ -3229,6 +3237,24 @@ class TestBugTargetKeys(TestCaseWithFactory): distribution=dsp.distribution, distroseries=None, sourcepackagename=dsp.sourcepackagename, + packagetype=None, + channel=None, + ociproject=None, + ), + ) + + def test_externalpackage(self): + externalpackage = self.factory.makeExternalPackage() + self.assertTargetKeyWorks( + externalpackage, + dict( + product=None, + productseries=None, + distribution=externalpackage.distribution, + distroseries=None, + sourcepackagename=externalpackage.sourcepackagename, + packagetype=externalpackage.packagetype, + channel=externalpackage.channel, ociproject=None, ), ) @@ -3243,6 +3269,8 @@ class TestBugTargetKeys(TestCaseWithFactory): distribution=None, distroseries=sp.distroseries, sourcepackagename=sp.sourcepackagename, + packagetype=None, + channel=None, ociproject=None, ), ) @@ -3258,6 +3286,8 @@ class TestBugTargetKeys(TestCaseWithFactory): distribution=pillar, distroseries=None, sourcepackagename=None, + packagetype=None, + channel=None, ociproject=ociproject, ), ) @@ -3273,6 +3303,8 @@ class TestBugTargetKeys(TestCaseWithFactory): distribution=None, distroseries=None, sourcepackagename=None, + packagetype=None, + channel=None, ociproject=ociproject, ), ) @@ -3292,6 +3324,8 @@ class TestBugTargetKeys(TestCaseWithFactory): None, None, None, + None, + None, ) @@ -3581,6 +3615,35 @@ class TestValidateTarget(TestCaseWithFactory, ValidateTargetMixin): dsp, ) + def test_externalpackage_task_is_allowed(self): + # An External task can coexist with a task for its Distribution. + d = self.factory.makeDistribution() + task = self.factory.makeBugTask(target=d) + externalpackage = self.factory.makeExternalPackage(distribution=d) + validate_target(task.bug, externalpackage) + + def test_different_externalpackage_tasks_are_allowed(self): + # An ExternalPackage task can also coexist with a task for another one. + externalpackage = self.factory.makeExternalPackage() + task = self.factory.makeBugTask(target=externalpackage) + externalpackage = self.factory.makeExternalPackage( + distribution=externalpackage.distribution + ) + validate_target(task.bug, externalpackage) + + def test_same_externalpackage_task_is_forbidden(self): + # But an ExternalPackage task cannot coexist with a task for itself. + externalpackage = self.factory.makeExternalPackage() + task = self.factory.makeBugTask(target=externalpackage) + self.assertRaisesWithContent( + IllegalTarget, + "A fix for this bug has already been requested for %s" + % (externalpackage.displayname), + validate_target, + task.bug, + externalpackage, + ) + def test_illegal_information_type_disallowed(self): # The bug's current information_type must be permitted by the # new target. @@ -3679,6 +3742,34 @@ class TestValidateNewTarget(TestCaseWithFactory, ValidateTargetMixin): d, ) + def test_externalpackage_task_with_distribution_task_forbidden(self): + d = self.factory.makeDistribution() + externalpackage = self.factory.makeExternalPackage(distribution=d) + task = self.factory.makeBugTask(target=d) + self.assertRaisesWithContent( + IllegalTarget, + "This bug is already open on %s with no package specified. " + "You should fill in a package name for the existing bug." + % d.displayname, + validate_new_target, + task.bug, + externalpackage, + ) + + def test_distribution_task_with_externalpackage_task_forbidden(self): + d = self.factory.makeDistribution() + externalpackage = self.factory.makeExternalPackage(distribution=d) + task = self.factory.makeBugTask(target=externalpackage) + self.assertRaisesWithContent( + IllegalTarget, + "This bug is already on %s. Please specify an affected " + "package in which the bug has not yet been reported." + % d.displayname, + validate_new_target, + task.bug, + d, + ) + class TestWebservice(TestCaseWithFactory): """Tests for the webservice.""" @@ -3827,6 +3918,10 @@ class TestBugTaskUserHasBugSupervisorPrivilegesContext(TestCaseWithFactory): dsp = self.factory.makeDistributionSourcePackage() self.assert_userHasBugSupervisorPrivilegesContext(dsp) + def test_externalpackage(self): + externalpackage = self.factory.makeExternalPackage() + self.assert_userHasBugSupervisorPrivilegesContext(externalpackage) + def test_product(self): product = self.factory.makeProduct() self.assert_userHasBugSupervisorPrivilegesContext(product) diff --git a/lib/lp/bugs/scripts/bugsummaryrebuild.py b/lib/lp/bugs/scripts/bugsummaryrebuild.py index 088ce97..baa6d8b 100644 --- a/lib/lp/bugs/scripts/bugsummaryrebuild.py +++ b/lib/lp/bugs/scripts/bugsummaryrebuild.py @@ -109,7 +109,8 @@ def load_target(pid, psid, did, dsid, spnid, ociproject_id): (pid, psid, did, dsid, spnid, ociproject_id), ), ) - return bug_target_from_key(p, ps, d, ds, spn, ociproject) + # TODO: modify when BugSummary for ExternalPackage implemented + return bug_target_from_key(p, ps, d, ds, spn, ociproject, None, None) def format_target(target): @@ -130,7 +131,15 @@ def format_target(target): def _get_bugsummary_constraint_bits(target): raw_key = bug_target_to_key(target) # Map to ID columns to work around Storm bug #682989. - return {"%s_id" % k: v.id if v else None for (k, v) in raw_key.items()} + constraint_bits = {} + for k, v in raw_key.items(): + # TODO: implement BugSummary for packagetype and channel + if k != "packagetype" and k != "channel": + key = "%s_id" % k + value = v.id if v else None + constraint_bits[key] = value + + return constraint_bits def get_bugsummary_constraint(target, cls=RawBugSummary): @@ -154,10 +163,15 @@ def get_bugtaskflat_constraint(target): if IProduct.providedBy(target): del raw_key["ociproject"] # Map to ID columns to work around Storm bug #682989. - return [ - getattr(BugTaskFlat, "%s_id" % k) == (v.id if v else None) - for (k, v) in raw_key.items() - ] + constraint = [] + for k, v in raw_key.items(): + # TODO: implement BugSummary for packagetype and channel + if k != "packagetype" and k != "channel": + key = "%s_id" % k + value = v.id if v else None + constraint.append(getattr(BugTaskFlat, key) == value) + + return constraint def get_bugsummary_rows(target): diff --git a/lib/lp/bugs/scripts/bugtasktargetnamecaches.py b/lib/lp/bugs/scripts/bugtasktargetnamecaches.py index 70a82d4..a1d408e 100644 --- a/lib/lp/bugs/scripts/bugtasktargetnamecaches.py +++ b/lib/lp/bugs/scripts/bugtasktargetnamecaches.py @@ -107,7 +107,10 @@ class BugTaskTargetNameCachesTunableLoop: (store.get(cls, id) if id is not None else None) for cls, id in zip(target_classes, target_bits) ) - target = bug_target_from_key(*target_objects) + + # We don't need packagetype and channel to get items from + # target_classes + target = bug_target_from_key(*target_objects, None, None) new_name = target.bugtargetdisplayname cached_names.discard(new_name) # If there are any outdated names cached, update them all in diff --git a/lib/lp/bugs/scripts/soss/__init__.py b/lib/lp/bugs/scripts/soss/__init__.py deleted file mode 100644 index db91701..0000000 --- a/lib/lp/bugs/scripts/soss/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# Copyright 2025 Canonical Ltd. This software is licensed under the -# GNU Affero General Public License version 3 (see the file LICENSE). - -from lp.bugs.scripts.soss.models import SOSSRecord # noqa: F401 diff --git a/lib/lp/bugs/tests/test_structuralsubscription.py b/lib/lp/bugs/tests/test_structuralsubscription.py index ce1f712..a22e0d6 100644 --- a/lib/lp/bugs/tests/test_structuralsubscription.py +++ b/lib/lp/bugs/tests/test_structuralsubscription.py @@ -548,6 +548,22 @@ class TestGetStructuralSubscriptionTargets(TestCaseWithFactory): }, ) + def test_externalpackage_target(self): + actor = self.factory.makePerson() + login_person(actor) + externalpackage = self.factory.makeExternalPackage() + product = self.factory.makeProduct() + bug = self.factory.makeBug(target=product) + bug.addTask(actor, externalpackage) + product_bugtask = bug.bugtasks[0] + result = get_structural_subscription_targets(bug.bugtasks) + self.assertEqual( + set(result), + { + (product_bugtask, product), + }, + ) + def test_product_with_project_group(self): # get_structural_subscription_targets() will yield both a # product and its parent project group if it has one. diff --git a/lib/lp/registry/browser/configure.zcml b/lib/lp/registry/browser/configure.zcml index 970fd02..f60b871 100644 --- a/lib/lp/registry/browser/configure.zcml +++ b/lib/lp/registry/browser/configure.zcml @@ -554,6 +554,10 @@ for="lp.registry.interfaces.distributionsourcepackage.IDistributionSourcePackage" urldata="lp.registry.browser.distributionsourcepackage.DistributionSourcePackageURL" /> + <lp:url + for="lp.registry.interfaces.externalpackage.IExternalPackage" + urldata="lp.registry.browser.distributionsourcepackage.DistributionSourcePackageURL" + /> <lp:navigation module="lp.registry.browser.distributionsourcepackage" classes=" diff --git a/lib/lp/registry/configure.zcml b/lib/lp/registry/configure.zcml index ac416ec..22e60a9 100644 --- a/lib/lp/registry/configure.zcml +++ b/lib/lp/registry/configure.zcml @@ -608,6 +608,21 @@ provides="lp.services.webapp.interfaces.IBreadcrumb" factory="lp.registry.browser.distributionsourcepackage.DistributionSourcePackageBreadcrumb"/> + <!-- ExternalPackage --> + <class + class="lp.registry.model.externalpackage.ExternalPackage"> + <allow + interface="lp.registry.interfaces.externalpackage.IExternalPackageView"/> + <require + permission="launchpad.BugSupervisor" + set_attributes=" + bug_reported_acknowledgement + bug_reporting_guidelines + content_templates + enable_bugfiling_duplicate_search + "/> + </class> + <!-- CommercialSubscription --> <class diff --git a/lib/lp/registry/interfaces/distribution.py b/lib/lp/registry/interfaces/distribution.py index ef966c7..336c843 100644 --- a/lib/lp/registry/interfaces/distribution.py +++ b/lib/lp/registry/interfaces/distribution.py @@ -878,6 +878,11 @@ class IDistributionView( distribution, or None. """ + def getExternalPackage(name, packagetype, channel): + """Return an ExternalPackage with the given name, packagetype and + channel for this distribution. + """ + def getSourcePackageRelease(sourcepackagerelease): """Returns an IDistributionSourcePackageRelease diff --git a/lib/lp/registry/interfaces/externalpackage.py b/lib/lp/registry/interfaces/externalpackage.py new file mode 100644 index 0000000..f2dccf9 --- /dev/null +++ b/lib/lp/registry/interfaces/externalpackage.py @@ -0,0 +1,148 @@ +# Copyright 2009, 2025 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""External package interfaces.""" + +__all__ = [ + "IExternalPackage", + "ExternalPackageType", +] + +from lazr.enum import DBEnumeratedType, DBItem +from lazr.restful.declarations import exported, exported_as_webservice_entry +from lazr.restful.fields import Reference +from zope.interface import Attribute +from zope.schema import TextLine + +from lp import _ +from lp.app.interfaces.launchpad import IHeadingContext +from lp.bugs.interfaces.bugtarget import IBugTarget, IHasOfficialBugTags +from lp.registry.interfaces.distribution import IDistribution +from lp.registry.interfaces.role import IHasDrivers + + +@exported_as_webservice_entry(as_of="beta") +class IExternalPackageView( + IHeadingContext, + IBugTarget, + IHasOfficialBugTags, + IHasDrivers, +): + """`IExternalPackage` attributes that require launchpad.View.""" + + packagetype = Attribute("The package type") + + channel = Attribute("The package channel") + + display_channel = TextLine(title=_("Display channel name"), readonly=True) + + distribution = exported( + Reference(IDistribution, title=_("The distribution.")) + ) + sourcepackagename = Attribute("The source package name.") + + name = exported( + TextLine(title=_("The source package name as text"), readonly=True) + ) + display_name = exported( + TextLine(title=_("Display name for this package."), readonly=True) + ) + displayname = Attribute("Display name (deprecated)") + title = exported( + TextLine(title=_("Title for this package."), readonly=True) + ) + + drivers = Attribute("The drivers for the distribution.") + + def __eq__(other): + """IExternalPackage comparison method. + + Distro sourcepackages compare equal only if their fields compare equal. + """ + + def __ne__(other): + """IExternalPackage comparison method. + + External packages compare not equal if either of their + fields compare not equal. + """ + + +@exported_as_webservice_entry(as_of="beta") +class IExternalPackage( + IExternalPackageView, +): + """Represents an ExternalPackage in a distribution. + + Create IExternalPackage by invoking `IDistribution.getExternalPackage()`. + """ + + +class ExternalPackageType(DBEnumeratedType): + """Bug Task Status + + The various possible states for a bugfix in a specific place. + """ + + SNAP = DBItem( + 1, + """ + Snap + + Snap external package + """, + ) + + CHARM = DBItem( + 2, + """ + Charm + + Charm external package + """, + ) + + ROCK = DBItem( + 3, + """ + Rock + + Rock external package + """, + ) + + PYTHON = DBItem( + 4, + """ + Python + + Python external package + """, + ) + + CONDA = DBItem( + 5, + """ + Conda + + Conda external package + """, + ) + + CARGO = DBItem( + 6, + """ + Cargo + + Cargo external package + """, + ) + + MAVEN = DBItem( + 7, + """ + Maven + + Maven external package + """, + ) diff --git a/lib/lp/registry/model/distribution.py b/lib/lp/registry/model/distribution.py index 310381a..646475d 100644 --- a/lib/lp/registry/model/distribution.py +++ b/lib/lp/registry/model/distribution.py @@ -145,6 +145,7 @@ from lp.registry.model.distributionsourcepackage import ( ) from lp.registry.model.distroseries import DistroSeries from lp.registry.model.distroseriesparent import DistroSeriesParent +from lp.registry.model.externalpackage import ExternalPackage from lp.registry.model.hasdrivers import HasDriversMixin from lp.registry.model.karma import KarmaContextMixin from lp.registry.model.milestone import HasMilestonesMixin, Milestone @@ -1359,6 +1360,18 @@ class Distribution( return None return DistributionSourcePackage(self, sourcepackagename) + def getExternalPackage(self, name, packagetype, channel): + """See `IDistribution`.""" + if ISourcePackageName.providedBy(name): + sourcepackagename = name + else: + sourcepackagename = getUtility(ISourcePackageNameSet).queryByName( + name + ) + if sourcepackagename is None: + return None + return ExternalPackage(self, sourcepackagename, packagetype, channel) + def getSourcePackageRelease(self, sourcepackagerelease): """See `IDistribution`.""" return DistributionSourcePackageRelease(self, sourcepackagerelease) diff --git a/lib/lp/registry/model/externalpackage.py b/lib/lp/registry/model/externalpackage.py new file mode 100644 index 0000000..2058462 --- /dev/null +++ b/lib/lp/registry/model/externalpackage.py @@ -0,0 +1,158 @@ +# Copyright 2009-2020 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Classes to represent external packages in a distribution.""" + +__all__ = [ + "ExternalPackage", +] + +from zope.interface import implementer + +from lp.bugs.model.bugtarget import BugTargetBase +from lp.bugs.model.structuralsubscription import ( + StructuralSubscriptionTargetMixin, +) +from lp.registry.interfaces.externalpackage import IExternalPackage +from lp.registry.model.hasdrivers import HasDriversMixin +from lp.services.propertycache import cachedproperty + +CHANNEL_FIELDS = ("track", "risk", "branch") + + +class ChannelFieldException(Exception): + """Channel fields are strings. + Track and Risk are required, Branch is optional. + """ + + +@implementer(IExternalPackage) +class ExternalPackage( + BugTargetBase, + HasDriversMixin, + StructuralSubscriptionTargetMixin, +): + """This is a "Magic External Package". It is not a Storm model, but instead + it represents a package with a particular name, type and channel in a + particular distribution. + """ + + def __init__(self, distribution, sourcepackagename, packagetype, channel): + self.distribution = distribution + self.sourcepackagename = sourcepackagename + self.packagetype = packagetype + + self.channel = self.validate_channel(channel) + + def __repr__(self) -> str: + return f"<{self.__class__.__name__} '{self.display_name}'>" + + def validate_channel(self, channel: dict) -> str: + if channel is None: + return None + if not isinstance(channel, dict): + raise ChannelFieldException("Channel should be a dict") + if "track" not in channel: + raise ChannelFieldException("Track is a required field in channel") + if "risk" not in channel: + raise ChannelFieldException("Risk is a required field in channel") + + for k, v in channel.items(): + if k not in CHANNEL_FIELDS: + raise ChannelFieldException( + f"{k} is not part of {CHANNEL_FIELDS}" + ) + if not isinstance(v, str): + raise ChannelFieldException( + "All channel fields should be a string" + ) + return channel + + @property + def name(self): + """See `IExternalPackage`.""" + return self.sourcepackagename.name + + @property + def display_channel(self): + """See `IExternalPackage`.""" + if not self.channel: + return None + + channel_list = [self.channel.get("track"), self.channel.get("risk")] + if (branch := self.channel.get("branch", "")) != "": + channel_list.append(branch) + + return "/".join(channel_list) + + @cachedproperty + def display_name(self): + """See `IExternalPackage`.""" + if self.channel: + return "%s - %s @%s in %s" % ( + self.sourcepackagename.name, + self.packagetype, + self.display_channel, + self.distribution.display_name, + ) + + return "%s - %s in %s" % ( + self.sourcepackagename.name, + self.packagetype, + self.distribution.display_name, + ) + + @property + def bugtargetdisplayname(self): + """See `IExternalPackage`.""" + return self.display_name + + @property + def bugtargetname(self): + """See `IExternalPackage`.""" + return self.display_name + + @property + def title(self): + """See `IExternalPackage`.""" + return self.display_name + + def __eq__(self, other): + """See `IExternalPackage`.""" + return ( + (IExternalPackage.providedBy(other)) + and (self.distribution.id == other.distribution.id) + and (self.sourcepackagename.id == other.sourcepackagename.id) + and (self.packagetype == other.packagetype) + and (self.channel == other.channel) + ) + + def __hash__(self): + """Return the combined attributes hash.""" + return hash( + ( + self.distribution, + self.sourcepackagename, + self.packagetype, + self.display_channel, + ) + ) + + @property + def drivers(self): + """See `IHasDrivers`.""" + return self.distribution.drivers + + @property + def official_bug_tags(self): + """See `IHasBugs`.""" + return self.distribution.official_bug_tags + + @property + def pillar(self): + """See `IBugTarget`.""" + return self.distribution + + def _getOfficialTagClause(self): + """See `IBugTarget`.""" + return self.distribution._getOfficialTagClause() diff --git a/lib/lp/registry/tests/test_distribution.py b/lib/lp/registry/tests/test_distribution.py index c1fafdd..c9fdb6d 100644 --- a/lib/lp/registry/tests/test_distribution.py +++ b/lib/lp/registry/tests/test_distribution.py @@ -67,6 +67,7 @@ from lp.registry.interfaces.accesspolicy import ( ) from lp.registry.interfaces.distribution import IDistribution, IDistributionSet from lp.registry.interfaces.distributionmirror import MirrorContent +from lp.registry.interfaces.externalpackage import ExternalPackageType from lp.registry.interfaces.oopsreferences import IHasOOPSReferences from lp.registry.interfaces.person import IPersonSet from lp.registry.interfaces.series import SeriesStatus @@ -379,6 +380,33 @@ class TestDistribution(TestCaseWithFactory): distro.getDefaultSpecificationInformationType(), ) + def test_getExternalPackage(self): + distro = self.factory.makeDistribution() + sourcepackagename = self.factory.getOrMakeSourcePackageName( + "my-package" + ) + channel = {"track": "22.04", "risk": "candidate", "branch": "staging"} + externalpackage = distro.getExternalPackage( + name=sourcepackagename, + packagetype=ExternalPackageType.ROCK, + channel=channel, + ) + self.assertEqual(externalpackage.distribution, distro) + self.assertEqual(externalpackage.name, "my-package") + self.assertEqual(externalpackage.packagetype, ExternalPackageType.ROCK) + self.assertEqual(externalpackage.channel, channel) + + # We can have external packages without channel + externalpackage = distro.getExternalPackage( + name=sourcepackagename, + packagetype=ExternalPackageType.SNAP, + channel=None, + ) + self.assertEqual(externalpackage.distribution, distro) + self.assertEqual(externalpackage.name, "my-package") + self.assertEqual(externalpackage.packagetype, ExternalPackageType.SNAP) + self.assertEqual(externalpackage.channel, None) + def test_getOCIProject(self): distro = self.factory.makeDistribution() first_project = self.factory.makeOCIProject(pillar=distro) diff --git a/lib/lp/registry/tests/test_externalpackage.py b/lib/lp/registry/tests/test_externalpackage.py new file mode 100644 index 0000000..30be4a2 --- /dev/null +++ b/lib/lp/registry/tests/test_externalpackage.py @@ -0,0 +1,218 @@ +# Copyright 2009-2025 Canonical Ltd. This software is licensed under the +# GNU Affero General Public License version 3 (see the file LICENSE). + +"""Tests for ExternalPackage.""" + +from zope.security.proxy import removeSecurityProxy + +from lp.registry.interfaces.externalpackage import ExternalPackageType +from lp.registry.model.externalpackage import ( + ChannelFieldException, + ExternalPackage, +) +from lp.testing import TestCaseWithFactory +from lp.testing.layers import DatabaseFunctionalLayer + + +class TestExternalPackage(TestCaseWithFactory): + layer = DatabaseFunctionalLayer + + def setUp(self): + super().setUp() + + self.sourcepackagename = self.factory.getOrMakeSourcePackageName( + "mypackage" + ) + self.channel = {"track": "12.81", "risk": "edge", "branch": "myfix"} + self.distribution = self.factory.makeDistribution(name="mydistro") + + self.externalpackage = self.distribution.getExternalPackage( + name=self.sourcepackagename, + packagetype=ExternalPackageType.SNAP, + channel=self.channel, + ) + self.externalpackage_maven = self.distribution.getExternalPackage( + name=self.sourcepackagename, + packagetype=ExternalPackageType.MAVEN, + channel=None, + ) + self.externalpackage_copy = ExternalPackage( + self.distribution, + sourcepackagename=self.sourcepackagename, + packagetype=ExternalPackageType.SNAP, + channel=self.channel, + ) + + def test_repr(self): + """Test __repr__ function""" + self.assertEqual( + "<ExternalPackage 'mypackage - Snap @12.81/edge/myfix in " + "Mydistro'>", + self.externalpackage.__repr__(), + ) + self.assertEqual( + "<ExternalPackage 'mypackage - Maven in Mydistro'>", + self.externalpackage_maven.__repr__(), + ) + + def test_name(self): + """Test name property""" + self.assertEqual("mypackage", self.externalpackage.name) + self.assertEqual("mypackage", self.externalpackage_maven.name) + + def test_display_channel(self): + """Test display name property""" + self.assertEqual( + self.externalpackage.display_channel, "12.81/edge/myfix" + ) + self.assertEqual(self.externalpackage_maven.display_channel, None) + + removeSecurityProxy(self.externalpackage).channel = { + "track": "12.81", + "risk": "candidate", + } + self.assertEqual( + "12.81/candidate", self.externalpackage.display_channel + ) + + def test_channel_fields(self): + """Test invalid channel fields when creating an ExternalPackage""" + self.assertRaises( + ChannelFieldException, + ExternalPackage, + self.distribution, + self.sourcepackagename, + ExternalPackageType.SNAP, + {}, + ) + self.assertRaises( + ChannelFieldException, + ExternalPackage, + self.distribution, + self.sourcepackagename, + ExternalPackageType.CHARM, + {"track": 16}, + ) + self.assertRaises( + ChannelFieldException, + ExternalPackage, + self.distribution, + self.sourcepackagename, + ExternalPackageType.CHARM, + {"track": "16"}, + ) + self.assertRaises( + ChannelFieldException, + ExternalPackage, + self.distribution, + self.sourcepackagename, + ExternalPackageType.ROCK, + {"risk": "beta"}, + ) + self.assertRaises( + ChannelFieldException, + ExternalPackage, + self.distribution, + self.sourcepackagename, + ExternalPackageType.PYTHON, + {"track": "16", "risk": "beta", "foo": "bar"}, + ) + self.assertRaises( + ChannelFieldException, + ExternalPackage, + self.distribution, + self.sourcepackagename, + ExternalPackageType.CONDA, + 1, + ) + + def test_display_name(self): + """Test display_name property""" + self.assertEqual( + "mypackage - Snap @12.81/edge/myfix in Mydistro", + self.externalpackage.display_name, + ) + self.assertEqual( + "mypackage - Maven in Mydistro", + self.externalpackage_maven.display_name, + ) + + def test_bugtargetdisplayname(self): + """Test bugtargetdisplayname property""" + self.assertEqual( + "mypackage - Snap @12.81/edge/myfix in Mydistro", + self.externalpackage.bugtargetdisplayname, + ) + self.assertEqual( + "mypackage - Maven in Mydistro", + self.externalpackage_maven.bugtargetdisplayname, + ) + + def test_bugtargetname(self): + """Test bugtargetname property""" + self.assertEqual( + "mypackage - Snap @12.81/edge/myfix in Mydistro", + self.externalpackage.bugtargetname, + ) + self.assertEqual( + "mypackage - Maven in Mydistro", + self.externalpackage_maven.bugtargetname, + ) + + def test_title(self): + """Test title property""" + self.assertEqual( + "mypackage - Snap @12.81/edge/myfix package in Mydistro", + self.externalpackage.title, + ) + self.assertEqual( + "mypackage - Maven package in Mydistro", + self.externalpackage_maven.title, + ) + + def test_compare(self): + """Test __eq__ and __neq__""" + self.assertEqual(self.externalpackage, self.externalpackage_copy) + self.assertNotEqual(self.externalpackage, self.externalpackage_maven) + + def test_hash(self): + """Test __hash__""" + self.assertEqual( + removeSecurityProxy(self.externalpackage).__hash__(), + removeSecurityProxy(self.externalpackage_copy).__hash__(), + ) + self.assertNotEqual( + removeSecurityProxy(self.externalpackage).__hash__(), + removeSecurityProxy(self.externalpackage_maven).__hash__(), + ) + + def test_pillar(self): + """Test pillar property""" + self.assertEqual(self.externalpackage.pillar, self.distribution) + + def test_official_bug_tags(self): + """Test official_bug_tags property""" + self.assertEqual( + self.externalpackage.official_bug_tags, + self.distribution.official_bug_tags, + ) + + def test__getOfficialTagClause(self): + """Test _getOfficialTagClause""" + self.assertEqual( + self.distribution._getOfficialTagClause(), + self.externalpackage._getOfficialTagClause(), + ) + + def test_drivers_are_distributions(self): + """Drivers property returns the drivers for the distribution.""" + self.assertNotEqual([], self.distribution.drivers) + self.assertEqual( + self.externalpackage.drivers, self.distribution.drivers + ) + + def test_personHasDriverRights(self): + """A distribution driver has driver permissions on an + externalpackage.""" + driver = self.distribution.drivers[0] + self.assertTrue(self.externalpackage.personHasDriverRights(driver)) diff --git a/lib/lp/testing/factory.py b/lib/lp/testing/factory.py index d23c481..8fcafbb 100644 --- a/lib/lp/testing/factory.py +++ b/lib/lp/testing/factory.py @@ -178,6 +178,7 @@ from lp.registry.interfaces.distroseriesdifferencecomment import ( IDistroSeriesDifferenceCommentSource, ) from lp.registry.interfaces.distroseriesparent import IDistroSeriesParentSet +from lp.registry.interfaces.externalpackage import ExternalPackageType from lp.registry.interfaces.gpg import IGPGKeySet from lp.registry.interfaces.mailinglist import ( IMailingListSet, @@ -5585,6 +5586,28 @@ class LaunchpadObjectFactory(ObjectFactory): ) return dsp + def makeExternalPackage( + self, + sourcepackagename=None, + packagetype=None, + channel=None, + distribution=None, + ): + if sourcepackagename is None or isinstance(sourcepackagename, str): + sourcepackagename = self.getOrMakeSourcePackageName( + sourcepackagename + ) + if distribution is None: + distribution = self.makeDistribution() + if packagetype is None: + packagetype = ExternalPackageType.SNAP + if channel is None: + channel = {"track": "12.1", "risk": "stable", "branch": ""} + + return distribution.getExternalPackage( + sourcepackagename, packagetype, channel + ) + def makeEmailMessage( self, body=None,
_______________________________________________ Mailing list: https://launchpad.net/~launchpad-reviewers Post to : launchpad-reviewers@lists.launchpad.net Unsubscribe : https://launchpad.net/~launchpad-reviewers More help : https://help.launchpad.net/ListHelp