Skip to content

ENT-5106: Type-hint subscription_manager/ files#3173

Merged
ptoscano merged 1 commit intomainfrom
mhorky/ENT-5106_type-hints_subman
Feb 20, 2023
Merged

ENT-5106: Type-hint subscription_manager/ files#3173
ptoscano merged 1 commit intomainfrom
mhorky/ENT-5106_type-hints_subman

Conversation

@m-horky
Copy link
Contributor

@m-horky m-horky commented Dec 5, 2022

  • Card ID: ENT-5106
  • Along with type hints, some typos in comments have been fixed and some import statements were reordered.

Unfortunately, it is a large PR due to the card scope. Most of the files should be easy to review though, as frequently there were only some minor changes.

Because type hints of function arguments and return values are evaluated at runtime, some have been put into quotes as strings -- they are not being loaded, they are imported only when TYPE_CHECKING is True. Variable arguments are not evaluated at runtime, so they do not need to be stringified.

@cnsnyder cnsnyder requested review from a team and ptoscano and removed request for a team December 5, 2022 10:45
@m-horky
Copy link
Contributor Author

m-horky commented Dec 7, 2022

#3181 needs to be merged first, as it resolves issues with writing to wrong locations.

@m-horky m-horky force-pushed the mhorky/ENT-5106_type-hints_subman branch from acc276e to bd503f3 Compare December 7, 2022 16:50
Copy link
Contributor

@ptoscano ptoscano left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ooof, lot to go through and check (with associated sadness due to the discovery of horrors done in the past)

I left various notes with things to fix in this PR, and things which would be better to fix outside of this (possibly even before this, simplifying things a bit).

Also, all around both Dict and dict are used: even if Dict is deprecated in Python 3.9, I think it'd be better to use Dict all around, so a) there is consistency b) new code that uses Dict can be easily backported to 1.28 if needed.

# taken wholseale from rho...
class CLI(object):
def __init__(self, command_classes=None):
def __init__(self, command_classes: List[AbstractCLICommand] = None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this should be List[Type[AbstractCLICommand]], since it is a list of class types and not actual instances

Comment on lines 396 to 397
# FIXME This return signature feels strange
def get_overall_status_code(self) -> Union[Dict, str]:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

more than strange, it seems wrong to me: the only place that calls this is the "status" command, and it is only compared against a static string:

syspurpose_status_code = syspurpose_cache.get_overall_status_code()
if syspurpose_status_code != "matched":
reasons = syspurpose_cache.get_status_reasons()
if reasons is not None:
for reason in reasons:
print("- {reason}".format(reason=reason))

which means that, if the server status is valid, the above check will be always false; then IMHO simply because there are no reasons (get_status_reasons() => None) when the status is valid and matched, then nothing happens to be printed

IMHO this ought to return server_status["status"], which is the status string -- something to change in a followup change

try:
f = open(self.CACHE_FILE)
data = self._load_data(f)
data: str = self._load_data(f)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm it seems to me that _load_data() returns Optional[Dict], not str -- at least, reading all the implementation of this method in all the cache subclasses


@classmethod
def from_uname_machine(cls, uname_machine, prefix=None):
def from_uname_machine(cls, uname_machine: str, prefix: str = None) -> BaseCpuInfo:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe Optional[str] instead of str?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, both mypy and PyCharm's type checker can convert this implicitly, when they see it being assigned to None, that's why it was not here. Updated per your suggestion for clarity.

@classmethod
def open_proc_cpuinfo(cls, prefix=None):
proc_cpuinfo_path = cls.proc_cpuinfo_path
def open_proc_cpuinfo(cls, prefix: str = None) -> str:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe Optional[str] instead of str?

Comment on lines 595 to 596
self.gpgkey_ssl_verify: Optional = None
self.repo_ssl_verify: Optional = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional[str] for both, from what I can see

release_source = YumReleaseverSource()

# query whether OCSP stapling is advertized by CP for the repositories
# query whether OCSP stapling is advertised by CP for the repositories
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

strictly speaking, "advertized" is correct (American English)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must have misconfigured the spellcheck.

"""

def __init__(self, product=None):
def __init__(self, product: "Product" = None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe Optional["Product"]?

"""
if not sys.stdout.isatty():
return None
# FIXME fallback should be integers
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used the non-integer fallback on purpose: this was the code can know when get_terminal_size() failed and returned the fallback, so the 1000 value can be used

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's ugly :(


class EntitlementCertificateFilter(ProductCertificateFilter):
def __init__(self, filter_string=None, service_level=None):
def __init__(self, filter_string: str = None, service_level=None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i guess Optional[str] for filter_string? also, did you forget service_level here? :)

Copy link
Contributor Author

@m-horky m-horky left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

both Dict and dict are used

That is true, but only when it is not further described: only List[dict], never dict[str, str]. That still makes it 3.6-compatible. I think I was quite consistent, but it may have slipped here and there. I only found one occasion of dict[] in cloud_facts.py, which I have fixed now.


@classmethod
def from_uname_machine(cls, uname_machine, prefix=None):
def from_uname_machine(cls, uname_machine: str, prefix: str = None) -> BaseCpuInfo:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, both mypy and PyCharm's type checker can convert this implicitly, when they see it being assigned to None, that's why it was not here. Updated per your suggestion for clarity.

return message

def format_using_error(self, exc: Exception, _: str) -> str:
def format_using_error(self, exc: Exception, _: Any) -> str:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function is only called once (in this file, in get_message) with the argument being None.

# TODO: we're using a Certificate which has it's own write/delete, no idea
# why this landed in a parallel disjoint class wrapping the actual cert.
def write(self):
# why this landed in a parallel disjoint class wrapping the actual cert.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When there are at least two spaces after the hash sign, PyCharm renders all the comment lines as TODO comment, not just the first line.
But we are not consistent in this (just in the 20 lines above there are both indented and non-indented follow-up lines). Reverting.


def main(self):
cmd = self._find_best_match(sys.argv)
def main(self) -> Optional[int]:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated with a FIXME.

Comment on lines +120 to +121
# FIXME Does not seem to be used
def fetch_certificates(certlib) -> Literal[True]:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather fix this after merging, to prevent further delays and rebases. There will be many minor things to fix, this is just one more to the list.

Comment on lines 354 to 355
# dict which does not contain all the pool info. Not sure if this is really
# necessary. Also some "view" specific things going on in here.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above. Reverting :(

Comment on lines 92 to 94
if not isinstance(name, str):
# FIXME This is not necessary in Python 3 code
name = name.decode("utf-8")
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as with the other ones, the PR should be a follow-up.

release_source = YumReleaseverSource()

# query whether OCSP stapling is advertized by CP for the repositories
# query whether OCSP stapling is advertised by CP for the repositories
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I must have misconfigured the spellcheck.

"""
if not sys.stdout.isatty():
return None
# FIXME fallback should be integers
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's ugly :(

@m-horky m-horky force-pushed the mhorky/ENT-5106_type-hints_subman branch 3 times, most recently from 3c8ba89 to bbf605b Compare February 2, 2023 13:44
Copy link
Contributor

@jirihnidek jirihnidek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this PR. 👍 I have few small requests. Next time please do not create so big PRs, when it is not necessary. Thanks.

@@ -12,71 +12,78 @@
# granted to use or replicate Red Hat trademarks that are incorporated
# in this software or its documentation.
#
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add at least empty line between GPL license and first line of code.


# clazz is the class object for class instance of the object the hook method maps too
def __init__(self, clazz, conf=None):
def __init__(self, clazz: type(SubManPlugin), conf: Optional["PluginConfig"] = None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't be better to use Type[SubManPlugin]? Calling type() in type hint looks wild.

slots = ["pre_register_consumer"]

def __init__(self, clazz, name, facts):
def __init__(self, clazz: type(SubManPlugin), name: str, facts: Dict[str, str]):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same comment here.

slots = ["pre_product_id_install", "post_product_id_install"]

def __init__(self, clazz, product_list):
def __init__(self, clazz: type(SubManPlugin), product_list: List[dict]):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here.

slots = ["pre_product_id_update", "post_product_id_update"]

def __init__(self, clazz, product_list):
def __init__(self, clazz: type(SubManPlugin), product_list: List[dict]):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And here and some other places...

remote: dict = None,
base: dict = None,
uep: "UEPConnection" = None,
consumer_uuid: str = None,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these arguments should be Optional.



def is_simple_content_access(uep=None, identity=None):
def is_simple_content_access(uep: "UEPConnection" = None, identity: Optional["Identity"] = None) -> bool:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The uep should be also Optional["UEPConnection"]



def get_current_owner(uep=None, identity=None):
def get_current_owner(uep: "UEPConnection" = None, identity: "Identity" = None) -> dict:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similar comment here.

@m-horky m-horky force-pushed the mhorky/ENT-5106_type-hints_subman branch 2 times, most recently from 38fe2b7 to e3272f6 Compare February 9, 2023 12:44
@github-actions
Copy link

github-actions bot commented Feb 9, 2023

Coverage

Coverage (computed on Fedora latest) •
FileStmtsMissCoverMissing
plugins/dnf
   product-id.py1471395%25–32, 34, 36, 39–40, 42–46, 48–49, 52–56, 58, 62, 64, 66–70, 72–79, 82, 85, 87–89, 91–92, 94–98, 100–104, 106, 108–113, 115, 119–122, 125–138, 142–146, 148–149, 151–152, 154, 156–157, 162–166, 168–180, 182–189, 191–194, 197–198, 200–201, 203–204, 206, 212–213, 215–216, 220, 222, 224
rhsm
   certificate2.py4235088%76–81, 105–108, 110, 143–147, 459–461, 523, 545–549, 576, 579–580, 582–585, 594, 632–633, 635, 662–667, 778, 780, 872, 891, 921, 929, 939, 943
   connection.py98643455%48–49, 53, 55–56, 81, 101–102, 150, 281, 312, 378–383, 387–396, 457, 459, 561, 564, 571–577, 582, 673–677, 679, 692, 719, 722–723, 725–726, 728, 755–759, 763, 767, 769–770, 774, 777, 781–783, 786–787, 802, 806, 808–809, 836–841, 843, 845–852, 854, 856, 861, 863–864, 866–869, 876–885, 887, 890–896, 898–899, 901–902, 913–916, 928–931, 936, 1000, 1002–1007, 1009, 1014–1018, 1021–1024, 1026–1031, 1035–1040, 1047, 1084, 1086, 1091, 1102, 1111–1114, 1118, 1120–1122, 1126–1127, 1129–1136, 1138, 1140, 1143–1150, 1153–1154, 1159, 1161, 1211, 1228–1231, 1255, 1278, 1308, 1313, 1316, 1319–1320, 1325, 1328, 1333, 1336, 1379–1383, 1390–1391, 1393, 1401–1402, 1404, 1421, 1434–1436, 1439, 1452, 1459, 1463, 1491–1493, 1498–1499, 1501–1502, 1504–1505, 1507–1521, 1523–1525, 1527–1538, 1540, 1557–1559, 1561–1563, 1565–1567, 1572, 1577–1579, 1584, 1611, 1643–1671, 1676–1677, 1679–1681, 1684–1685, 1688–1689, 1692–1693, 1712–1713, 1722–1723, 1733–1734, 1741–1742, 1748–1751, 1757–1760, 1766–1767, 1773–1774, 1794–1795, 1804–1808, 1816–1817, 1843–1846, 1871–1872, 1881–1882, 1890–1891, 1912, 1914–1916, 1918, 1920, 1923, 1925–1938, 1940–1941, 1950–1952, 1964–1965, 1974–1975, 1977, 1979–1981, 1988–1990, 1999–2001, 2009–2010, 2021, 2023–2024, 2026, 2028–2031, 2033–2035, 2038, 2040, 2047–2048, 2055–2056, 2066–2067, 2077–2080, 2090–2096
rhsmlib
   file_monitor.py1673579%31–32, 120, 122–125, 171, 173–174, 176–178, 195–196, 220, 276–277, 295–296, 310–312, 318, 324–329, 343–346, 396
rhsmlib/facts
   cloud_facts.py76692%100, 105, 109, 158, 164, 166
rhsmlib/services
   syspurpose.py441761%60–64, 72–74, 77–80, 82–85, 87
subscription_manager
   action_client.py44197%25
   base_action_client.py45491%23–24, 46, 69
   base_plugin.py18477%17, 37, 39, 42
   cache.py57811879%29–32, 73, 80, 86, 92, 97–99, 120, 126–128, 142–145, 148, 196, 198, 237–240, 247–250, 267, 270–272, 288–289, 291, 300, 341–342, 387–388, 394, 401, 418, 420–421, 423, 434, 482, 486, 491, 496–499, 502, 510, 513–514, 535, 587, 592, 617, 718, 736, 771, 773–774, 803, 806–812, 815, 847–850, 852, 863, 885, 907–908, 948–951, 953, 979, 1010–1011, 1036–1037, 1062–1063, 1067, 1071, 1073, 1081, 1121, 1149–1155, 1157, 1159, 1166–1169, 1171
   cert_sorter.py2535478%30–34, 149–150, 173–176, 178, 275, 319, 334–337, 341–347, 400, 402, 407, 411–412, 415, 418–422, 425–426, 430, 436–437, 443, 449–450, 454, 477, 480, 495, 498–499, 501, 504–505, 507
   certdirectory.py2242389%29, 45, 72, 80, 85–86, 288, 290–291, 298, 313, 320–321, 384–385, 387–390, 392–395
   certlib.py46295%7, 56
   cli.py1091982%44–45, 71, 91, 116, 119, 174, 178, 189–190, 192–196, 198–200, 203
   content_action_client.py401172%26–27, 62, 65, 67, 83–84, 87–88, 116–117
   cp_provider.py1291092%125, 158, 201, 207, 214–218, 220
   cpuinfo.py2042985%166, 170–171, 173–174, 176, 181, 184, 189–199, 484, 486, 488, 490–491, 495–499
   entbranding.py93594%23, 73, 76, 79, 92
   entcertlib.py2705480%32–33, 35–38, 64–65, 68, 71–72, 80, 83–88, 133–135, 160–168, 171, 203, 207, 209, 213, 230–232, 251, 254–255, 257, 260–261, 285, 368, 377, 427–428, 430–431, 433, 480, 484
   exceptions.py93396%170–171, 173
   factlib.py44393%20–22
   facts.py471274%24, 57–58, 79–84, 87, 90–91
   healinglib.py67888%26–30, 120, 128, 134
   i18n.py882571%47, 52–58, 62–63, 86, 93, 112–115, 117–118, 120, 144–145, 163–164, 176–177
   i18n_argparse.py140100% 
   identity.py1304962%30, 57–60, 64, 68–75, 78, 81–82, 85–86, 90, 94, 97, 102, 104–110, 113–118, 121–123, 126, 158, 181–183, 193–195, 199, 202
   identitycertlib.py36391%22–24
   injection.py51296%78–79
   installedproductslib.py25484%17, 19–21
   isodate.py150100% 
   jsonwrapper.py56394%47, 50, 56
   listing.py200100% 
   lock.py1481490%106, 108–109, 124, 148–149, 151, 158, 162, 167, 177, 212–213, 221
   managercli.py551278%22, 94–96, 98, 100–105, 109
   managerlib.py49315368%62–69, 90–91, 99–105, 110, 114, 117, 123–126, 128, 154–159, 161–166, 224–228, 290, 292–296, 412, 420, 443, 477, 578–579, 581–585, 588–589, 593–594, 600–602, 604, 608, 610–613, 635, 637, 670, 703–705, 709–711, 714–715, 718, 741–743, 751–754, 756–759, 811–814, 835, 876, 897–899, 901, 904–905, 931–933, 935, 946, 960–963, 965–968, 972–975, 977, 980, 983–984, 988–991, 993, 996, 999–1000, 1002–1005, 1007, 1011–1014, 1022–1027, 1029–1030
   overrides.py501472%23–25, 40, 43, 46, 49, 52–54, 57, 72, 75, 86
   packageprofilelib.py25484%15–18
   plugins.py3793790%29–30, 470, 504–505, 508–512, 722, 777, 794–799, 801–804, 808, 810, 813, 819–821, 823–824, 896–899, 979–981
   printing_utils.py112496%94, 99, 158–159
   productid.py3524487%247, 250, 278, 285, 291, 324, 328, 342, 344–345, 360, 362–363, 367–368, 699–701, 705, 710–711, 716–718, 723, 726–727, 749–750, 752–759, 761, 765, 767, 769–770, 772–773
   reasons.py861286%75–83, 89, 110, 137
   release.py119992%36–39, 59, 66, 71–72, 173
   repofile.py40613267%44–45, 128–129, 153–157, 165, 211, 219, 255–261, 263, 266, 275–280, 359, 369–370, 422–427, 430–437, 455–457, 459, 462, 470–472, 536–537, 539–542, 549, 557, 564, 588–591, 595–596, 603–614, 617–625, 627–633, 637–638, 641–642, 645–646, 649–650, 652, 655–656, 658, 660–662, 664–666, 668, 670–682, 684, 687, 689, 694, 700
   repolib.py3725684%41, 43–47, 80–85, 88, 93, 99, 130, 133, 180–181, 235–236, 246–252, 254, 400–403, 405–406, 408, 410, 412–414, 426–432, 475–478, 486–487, 494, 548, 638
   rhelentbranding.py97495%25, 27, 49–50
   rhelproduct.py10190%30
   syspurposelib.py1374368%31, 33–35, 37, 96–97, 99–101, 103–105, 134–139, 176, 183, 185, 211–214, 216–221, 224–225, 229–230, 240–241, 273, 277–280
   unicode_width.py24291%108, 216
   utils.py3226280%58–59, 61, 63–65, 77, 80, 113, 162–165, 168–169, 171–173, 176–181, 195, 199, 212–213, 257–259, 262, 274, 276–279, 311–312, 316–318, 322, 342, 348–352, 354–355, 357, 359–361, 363, 403, 413, 611, 615, 647–648
   validity.py33487%23, 25–26, 56
subscription_manager/cli_command
   cli.py2093185%65, 69, 124, 128–129, 144, 201, 205, 207, 250, 290, 301–303, 317–319, 343, 363, 389, 398–399, 403–404, 421, 423–424, 426–427, 432, 440
TOTAL18060442975% 

Tests Skipped Failures Errors Time
2620 6 💤 0 ❌ 0 🔥 55.547s ⏱️

Copy link
Contributor

@jirihnidek jirihnidek left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for updates. 👍 LGTM

Copy link
Contributor

@ptoscano ptoscano left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mostly LGTM now; I left a couple of easy notes, and please notice an unfixed note (still about Optional)

also please rebase it, so we can get updates and CI improvements

self.system_status = None
self.valid_entitlement_certs = None
self.status = None
def __init__(self, on_date: datetime = None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional[datetime]

Comment on lines 73 to 84
self.on_date: datetime = on_date
self.installed_products: Dict[str, List[EntitlementCertificate]] = None
self.unentitled_products: Dict[str, List[EntitlementCertificate]] = None
self.expired_products: Dict[str, List[EntitlementCertificate]] = None
self.partially_valid_products: Dict[str, List[EntitlementCertificate]] = None
self.valid_products: Dict[str, List[EntitlementCertificate]] = None
self.partial_stacks: Dict[str, List[EntitlementCertificate]] = None
self.future_products: Dict[str, List[EntitlementCertificate]] = None
self.reasons: Reasons = None
self.supports_reasons: bool = False
self.system_status: str = None
self.valid_entitlement_certs: List[EntitlementCertificate] = None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all of them (except the bool one) should be Optional[...]

@m-horky m-horky force-pushed the mhorky/ENT-5106_type-hints_subman branch from e3272f6 to 3b5766f Compare February 16, 2023 13:47
Copy link
Contributor

@ptoscano ptoscano left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Jirka approved, and now I do it too.

Will wait for the successful completion of the CI jobs before merging.

@ptoscano
Copy link
Contributor

The rhel-8-8 job fails, I guess because of incompatibilities with Python < 3.9; would it be possible to fix it?

* Card ID: ENT-5106

- Along with type hints, some typos in comments have been fixed and some
  import statements were reordered.
@ptoscano ptoscano force-pushed the mhorky/ENT-5106_type-hints_subman branch from 3b5766f to 980ae50 Compare February 20, 2023 17:27
@ptoscano ptoscano merged commit 0c563ca into main Feb 20, 2023
@ptoscano ptoscano deleted the mhorky/ENT-5106_type-hints_subman branch February 20, 2023 18:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants