From 5a1f7122bd3de07e48fdfc025ab144014371cdbe 2020-04-29 15:23:48 From: Brett Smith Date: 2020-04-29 15:23:48 Subject: [PATCH] rtutil: Add RT.iter_urls() method. --- diff --git a/conservancy_beancount/rtutil.py b/conservancy_beancount/rtutil.py index 183cd1e9525a5b5cdc1828eafdc6f9ca01bc5969..8e98ca99fb934fedf877ac0079848ac3cad834d8 100644 --- a/conservancy_beancount/rtutil.py +++ b/conservancy_beancount/rtutil.py @@ -271,55 +271,40 @@ class RT: ) return self._extend_url(path_tail) - def _urls(self, links: Iterable[str]) -> Iterator[str]: - for link in links: - parsed = self.parse(link) - if parsed is None: - yield link - else: - ticket_id, attachment_id = parsed - url = self.url(ticket_id, attachment_id) - yield f'<{url}>' + def exists(self, ticket_id: RTId, attachment_id: Optional[RTId]=None) -> bool: + return self.url(ticket_id, attachment_id) is not None - @overload - def _meta_with_urls(self, meta: None) -> None: ... + def iter_urls(self, + links: Iterable[str], + rt_fmt: str='{}', + nonrt_fmt: str='{}', + missing_fmt: str='{}', + ) -> Iterator[str]: + """Iterate over metadata links, replacing RT references with web URLs - @overload - def _meta_with_urls(self, meta: bc_data.Meta) -> bc_data.Meta: ... + This method iterates over metadata link strings (e.g., from + Metadata.get_links()) and transforms them for web presentation. - def _meta_with_urls(self, meta: Optional[bc_data.Meta]) -> Optional[bc_data.Meta]: - if meta is None: - return None - link_meta = data.Metadata(meta) - retval = meta.copy() - for key in data.LINK_METADATA: - try: - links = link_meta.get_links(key) - except TypeError: - continue - if links: - retval[key] = ' '.join(self._urls(links)) - return retval + If the string is a valid RT reference, the corresponding web URL + will be formatted with ``rt_fmt``. - def txn_with_urls(self, txn: Transaction) -> Transaction: - """Copy a transaction with RT references replaced with web URLs + If the string is a well-formed RT reference but the object doesn't + exist, it will be formatted with ``missing_fmt``. - Given a Beancount Transaction, this method returns a Transaction - that's identical, except any references to RT in the metadata for - the Transaction and its Postings are replaced with web URLs. - This is useful for reporting tools that want to format the - transaction with URLs that are recognizable by other tools. - """ - # mypy doesn't recognize that postings is a valid argument, probably a - # bug in the NamedTuple→Directive→Transaction hierarchy. - return txn._replace( # type:ignore[call-arg] - meta=self._meta_with_urls(txn.meta), - postings=[post._replace(meta=self._meta_with_urls(post.meta)) - for post in txn.postings], - ) + All other link strings will be formatted with ``nonrt_fmt``. - def exists(self, ticket_id: RTId, attachment_id: Optional[RTId]=None) -> bool: - return self.url(ticket_id, attachment_id) is not None + """ + for link in links: + parsed = self.parse(link) + if parsed is None: + yield nonrt_fmt.format(link) + else: + ticket_id, attachment_id = parsed + url = self.url(ticket_id, attachment_id) + if url is None: + yield missing_fmt.format(link) + else: + yield rt_fmt.format(url) @classmethod def metadata_regexp(self, @@ -365,6 +350,68 @@ class RT: return None return self._ticket_url(ticket_id) + @overload + def _meta_with_urls(self, + meta: None, + rt_fmt: str, + nonrt_fmt: str, + missing_fmt: str, + ) -> None: ... + + @overload + def _meta_with_urls(self, + meta: bc_data.Meta, + rt_fmt: str, + nonrt_fmt: str, + missing_fmt: str, + ) -> bc_data.Meta: ... + + def _meta_with_urls(self, + meta: Optional[bc_data.Meta], + rt_fmt: str, + nonrt_fmt: str, + missing_fmt: str, + ) -> Optional[bc_data.Meta]: + if meta is None: + return None + link_meta = data.Metadata(meta) + retval = meta.copy() + for key in data.LINK_METADATA: + try: + links = link_meta.get_links(key) + except TypeError: + links = () + if links: + retval[key] = ' '.join(self.iter_urls( + links, rt_fmt, nonrt_fmt, missing_fmt, + )) + return retval + + def txn_with_urls(self, txn: Transaction, + rt_fmt: str='<{}>', + nonrt_fmt: str='{}', + missing_fmt: str='{}', + ) -> Transaction: + """Copy a transaction with RT references replaced with web URLs + + Given a Beancount Transaction, this method returns a Transaction + that's identical, except any references to RT in the metadata for + the Transaction and its Postings are replaced with web URLs. + This is useful for reporting tools that want to format the + transaction with URLs that are recognizable by other tools. + + The format string arguments have the same meaning as RT.iter_urls(). + See that docstring for details. + """ + # mypy doesn't recognize that postings is a valid argument, probably a + # bug in the NamedTuple→Directive→Transaction hierarchy. + return txn._replace( # type:ignore[call-arg] + meta=self._meta_with_urls(txn.meta, rt_fmt, nonrt_fmt, missing_fmt), + postings=[post._replace(meta=self._meta_with_urls( + post.meta, rt_fmt, nonrt_fmt, missing_fmt, + )) for post in txn.postings], + ) + def url(self, ticket_id: RTId, attachment_id: Optional[RTId]=None) -> Optional[str]: if attachment_id is None: return self.ticket_url(ticket_id) diff --git a/tests/test_rtutil.py b/tests/test_rtutil.py index e70c488ed6acc29f296dddb2b43ec8a822e33238..7c285d06235c9a68fd38f15d9b168c567fe9f786 100644 --- a/tests/test_rtutil.py +++ b/tests/test_rtutil.py @@ -118,6 +118,30 @@ def test_url_default_filename(new_client, mimetype, extension): expected = '{}Ticket/Attachment/9/9/RT1%20attachment%209.{}'.format(DEFAULT_RT_URL, extension) assert rt.url(1, 9) == expected +@pytest.mark.parametrize('rt_fmt,nonrt_fmt,missing_fmt', [ + ('{}', '{}', '{}',), + ('<{}>', '[{}]', '({})'), +]) +def test_iter_urls(rt, rt_fmt, nonrt_fmt, missing_fmt): + expected_map = { + 'rt:{}{}'.format(tid, '' if aid is None else f'/{aid}'): url + for tid, aid, url in EXPECTED_URLS + } + expected_map['https://example.com'] = None + expected_map['invoice.pdf'] = None + keys = list(expected_map) + urls = rt.iter_urls(keys, rt_fmt, nonrt_fmt, missing_fmt) + for key, actual in itertools.zip_longest(keys, urls): + expected = expected_map[key] + if expected is None: + if key.startswith('rt:'): + expected = missing_fmt.format(key) + else: + expected = nonrt_fmt.format(key) + else: + expected = rt_fmt.format(DEFAULT_RT_URL + expected) + assert actual == expected + @pytest.mark.parametrize('ticket_id,attachment_id,expected', EXPECTED_URLS) def test_exists(rt, ticket_id, attachment_id, expected): expected = False if expected is None else True @@ -239,3 +263,21 @@ def test_txn_with_urls(rt): assert txn.meta[key] == expected assert txn.postings[0].meta['receipt'] == 'rt:2/13 donation.txt' assert txn.postings[1].meta['receipt'] == 'cash.png rt:2/14' + +def test_txn_with_urls_with_fmts(rt): + txn_meta = { + 'rt-id': 'rt:1', + 'contract': 'RepoLink.pdf', + 'statement': 'rt:1/99 rt:1/4 stmt.txt', + } + txn = testutil.Transaction(**txn_meta) + actual = rt.txn_with_urls(txn, '<{}>', '[{}]', '({})') + rt_id_path = EXPECTED_URLS_MAP[(1, None)] + assert actual.meta['rt-id'] == f'<{DEFAULT_RT_URL}{rt_id_path}>' + assert actual.meta['contract'] == '[RepoLink.pdf]' + statement_path = EXPECTED_URLS_MAP[(1, 4)] + assert actual.meta['statement'] == ' '.join([ + '(rt:1/99)', + f'<{DEFAULT_RT_URL}{statement_path}>', + '[stmt.txt]', + ])