diff --git a/README.org b/README.org index 78d1651..f6dc088 100644 --- a/README.org +++ b/README.org @@ -1,90 +1,136 @@ # -*- org-confirm-babel-evaluate: nil; -*- -Orger converts your data into Org-mode to allow for quick access and search. +Orger converts your data into a hierarchical Org-mode representation to allow for quick access and search. I write in detail about usecases and motivation for it [[https://beepb00p.xyz/orger.html][here]], this readme is mostly the setup manual! * Installing -[[https://pypi.org/project/orger][PyPi]]: ~pip3 install --user orger~ +- simplest: install from [[https://pypi.org/project/orger][PyPi]]: ~pip3 install --user orger~ + + After that you should be able to run orger modules via =python3 -m=: + + : python3 -m orger.modules.instapaper --help + +- [[https://pip.pypa.io/en/stable/reference/pip_install/#editable-installs][editable]] install + + This will allow you to quickly prototype and debug, the local changes to the code will be reflected immiedately. + + - clone: =https://github.com/karlicoss/orger /path/to/orger= + - =cd /path/to/orger= + - =pip3 install --user .= + - after that you can use =python3 -m orger.modules.modulename=, same way as the previous section, or run =modules/modulename.py= directly * Usage and examples -There are several examples [[https://beepb00p.xyz/orger.html#examples][here]], including a [[https://www.youtube.com/watch?v=ib_PDJpTh-Q][screencast]]. - -See [[./modules/pocket_demo.py][pocket_demo]] for a documented literate demo and [[./modules][modules]] for some of modules I'm using. - -Here's a quick minimalistic demo. - -#+BEGIN_SRC python - from orger import StaticView - from orger.inorganic import node, link - from orger.common import dt_heading - - import my.coding.github as github_data - - class Github(StaticView): - def get_items(self): - for event in github_data.get_events(): - yield node(dt_heading(event.dt, event.summary)) - - Github.main() -#+END_SRC - -That ten line program results in a file =Github.org=: - -#+BEGIN_SRC org - # AUTOGENERATED BY /code/orger/github.py - - ,* [2016-10-30 Sun 10:29] opened PR Add __enter__ and __exit__ to Pool stub - ,* [2016-11-10 Thu 09:29] opened PR Update gradle to 2.14.1 and gradle plugin to 2.1.1 - ,* [2016-11-16 Wed 20:20] commented on issue Linker error makes it impossible to use a stack-provided ghc - ,* [2016-12-30 Fri 11:57] commented on issue Fix performance in the rare case of hashCode evaluating to zero - ,* [2019-09-21 Sat 16:51] commented on issue Tags containing letters outside of a-zA-Z - .... -#+END_SRC - -I run it automatically through Cron every night. - -The trick to accessing data is in ~import my.coding.github~. -You can learn about setting it up and using [[https://github.com/karlicoss/HPI][here]]. - - -# TODO Use :session t??? - - -** types of views - - - #+BEGIN_SRC python :exports results :results raw - import sys - sys.path.insert(0, 'src') - import orger - return orger.org_view.StaticView.__doc__ - #+END_SRC - - You can run static module as: - - #+BEGIN_SRC bash - ./orger_module.py --to /path/to/output.org - #+END_SRC - - - - - #+BEGIN_SRC python :exports results :results raw - import sys - sys.path.insert(0, 'src') - import orger - return orger.org_view.InteractiveView.__doc__ - #+END_SRC +I usually run Orger modules overnight via cron. + +- see [[./modules][modules]] for *all available modules* +- Most modules are using [[https://github.com/karlicoss/HPI][HPI]] package for accessing the data. + You can learn about setting it up and using [[https://github.com/karlicoss/HPI/blob/master/doc/SETUP.org#orger][here]]. +- several examples [[https://beepb00p.xyz/orger.html#examples][here]] +- [[https://beepb00p.xyz/myinfra-roam.html#orger][demonstration]] of Roam Research module, including a [[https://www.youtube.com/watch?v=ib_PDJpTh-Q][screencast]] +- [[./modules/pocket_demo.py][pocket_demo]]: documented literate demo +- and a short short demo: + + #+BEGIN_SRC python + from orger import Mirror + from orger.inorganic import node, link + from orger.common import dt_heading + + import my.coding.github as github_data + + class Github(Mirror): + def get_items(self): + for event in github_data.get_events(): + yield node(dt_heading(event.dt, event.summary)) + + Github.main() + #+END_SRC + + That ten line program, when run (=./modules/github.py=), results in a file =Github.org=: + + #+BEGIN_SRC org + # AUTOGENERATED BY /code/orger/github.py + + ,* [2016-10-30 Sun 10:29] opened PR Add __enter__ and __exit__ to Pool stub + ,* [2016-11-10 Thu 09:29] opened PR Update gradle to 2.14.1 and gradle plugin to 2.1.1 + ,* [2016-11-16 Wed 20:20] commented on issue Linker error makes it impossible to use a stack-provided ghc + ,* [2016-12-30 Fri 11:57] commented on issue Fix performance in the rare case of hashCode evaluating to zero + ,* [2019-09-21 Sat 16:51] commented on issue Tags containing letters outside of a-zA-Z + .... + #+END_SRC + +* types of modules +- Mirror + + #+begin_src python :dir src :exports results :results drawer output +import orger +print(orger.Mirror.__doc__) + #+end_src + + #+RESULTS: + :results: + + *Mirror* (old name =StaticView=): mirrors *all data* from a source, and generated from scratch every time, hence *read only*. + + :end: + + You can run such module with + + : ./orger_module.py --to /path/to/output.org + +- Queue + + #+BEGIN_SRC python :dir src :exports results :results drawer output +import orger +print(orger.Queue.__doc__) + #+END_SRC + + #+RESULTS: + :results: + + *Queue* (old name =InteractiveView=): works as a queue, *only previously unseen items* from the data source are appended to the output org-mode file. + + To keep track of old/new items, it's using a separate JSON =state= file. + + A typical usecase is a todo list, or a content processing queue. + You can use such a module as you use any other org-mode file: schedule/refile/comment/set priorities, etc. + + :end: + Typically you'd want to use these as a source of tasks for your todo list. See [[./modules/ip2org.py][ip2org]] as an example. - You can run interactive module as: + You can run such a module as: + + : # initialize the state file first to avoid surprises (you only need to do it once) + : ./orger_module.py --to /path/to/output.org --state /path/to/state.json --init + : # after that you can just run it: + : ./orger_module.py --to /path/to/output.org --state /path/to/state.json + +* FAQ +- Why are the files output by some modules read only? + + =Mirror= type modules output read only files, so you don't modify them by accident, they are overwritten every time. + + If you want to temporary lift this restriction (e.g. to experiment with the format), you can use =chmod +w=, or =M-x toggle-read-only= in Emacs. + +- How is it different from [[https://github.com/novoid/Memacs][Memacs]]? + + The main reason Orger exists is because I discovered Memacs after I wrote Orger! + One day we might merge them, or at least [[https://github.com/karlicoss/orger/issues/5][reuse org-mode formatting routines]]. + + That said there are some differences at the moment: + + - Memacs is more of a lifelogging utility, generating a linear output with the intent to be used with your org agenda + - Orger's =Mirror= modules are meant to be more of a full local reflection of a data source, preserving the hierarchy as much as possible + - Orger's =Queue= module: I believe they don't have Memacs analogue (but please correct me if I'm wrong) + - Orger modules are slim and relying on [[https://github.com/karlicoss/HPI][HPI]] to encapsulate data access. But you can also use HPI with Memacs, please ping me if you set up such an integration! + +- I want active timestamps for org-agenda integration + + Pass the =--timestamp= argument to the module, for example: - #+BEGIN_SRC bash - # initialise the state file first to avoid surprises (you only need to do it once) - ./orger_module.py --to /path/to/output.org --state /path/to/state.json --init - # after that you can just run it: - ./orger_module.py --to /path/to/output.org --state /path/to/state.json - #+END_SRC + : modules/polar.py --timestamps active * Similar projects - [[https://github.com/novoid/Memacs][Memacs by novoid]] diff --git a/modules/instapaper.py b/modules/instapaper.py index 68ece85..c9c50a3 100755 --- a/modules/instapaper.py +++ b/modules/instapaper.py @@ -1,12 +1,12 @@ #!/usr/bin/env python3 -from orger import StaticView +from orger import Mirror from orger.inorganic import node, link from orger.common import dt_heading from my.instapaper import pages -class IpView(StaticView): +class Instapaper(Mirror): def get_items(self): for page in pages(): yield node( @@ -28,11 +28,11 @@ def get_items(self): # TODO better error handling, cooperate with org_tools # TODO move tests to separate files, otherwise they would annoy other people -test = IpView.make_test( +test = Instapaper.make_test( heading='Life Extension Methods', contains='sleep a lot', ) if __name__ == '__main__': - IpView.main() + Instapaper.main() diff --git a/modules/materialistic.py b/modules/materialistic.py index 42154fb..eb4c11f 100755 --- a/modules/materialistic.py +++ b/modules/materialistic.py @@ -4,13 +4,13 @@ https://play.google.com/store/apps/details?id=io.github.hidroh.materialistic """ -from orger import InteractiveView +from orger import Queue from orger.inorganic import node, link from orger.common import dt_heading from my.materialistic import saves -class MaterialisticView(InteractiveView): +class Materialistic(Queue): def get_items(self): for s in saves(): yield s.uid, node( @@ -23,4 +23,4 @@ def get_items(self): if __name__ == '__main__': - MaterialisticView.main() + Materialistic.main() diff --git a/modules/pocket_demo.py b/modules/pocket_demo.py index e5b8ef8..b0a8054 100755 --- a/modules/pocket_demo.py +++ b/modules/pocket_demo.py @@ -74,15 +74,15 @@ def get_articles(json_path: Path) -> Sequence[Article]: """ Ok, now we can get to implementing the adapter. """ -from orger import StaticView +from orger import Mirror """ -StaticView means it's meant to be read-only view onto data (as opposed to InteractiveView). +Mirror means it's meant to be read-only view onto data (as opposed to Queue). """ from orger.inorganic import node, link from orger.common import dt_heading -class PocketView(StaticView): +class Pocket(Mirror): def get_items(self): """ get_items returns a sequence/iterator of nodes @@ -113,7 +113,7 @@ def setup_parser(p): """ Usage example: ./pocket.py --file /backups/pocket/last-backup.json --to /data/orger/pocket.org """ - PocketView.main(setup_parser=setup_parser) + Pocket.main(setup_parser=setup_parser) """ Example pocket.org output: diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..0f8386f --- /dev/null +++ b/mypy.ini @@ -0,0 +1,10 @@ +[mypy] +pretty = True +show_error_context = True +show_error_codes = True +check_untyped_defs = True +namespace_packages = True + +# an example of suppressing +# [mypy-my.config.repos.pdfannots.pdfannots] +# ignore_errors = True diff --git a/pytest.ini b/pytest.ini index 890c5d2..db07aea 100644 --- a/pytest.ini +++ b/pytest.ini @@ -3,6 +3,7 @@ python_files = *.py addopts = --verbose + --ignore=src/orger/modules # otherwise it won't discover doctests --doctest-modules diff --git a/setup.py b/setup.py index 19dd783..2a8f33f 100644 --- a/setup.py +++ b/setup.py @@ -17,8 +17,13 @@ def main(): zip_safe=False, - packages=[pkg], - package_dir={'': 'src'}, + packages=[pkg, 'orger.modules'], + # TODO ugh, it's weird. If it's editable install, python3 -m 'orger.modules.module' doesn't work. But it does if installed without editable?? + # for not solved with a symlink... + package_dir={ + '': 'src', + 'orger.modules': 'modules', + }, package_data={pkg: ['py.typed']}, ## ^^^ this should be mostly automatic and not requiring any changes @@ -31,7 +36,7 @@ def main(): install_requires=['atomicwrites'], extras_require={ 'testing': ['pytest'], - 'linting': ['pytest', 'mypy', 'pylint'], + 'linting': ['pytest', 'mypy'], }, ) diff --git a/src/orger/__init__.py b/src/orger/__init__.py index 98fbca3..03e3f21 100644 --- a/src/orger/__init__.py +++ b/src/orger/__init__.py @@ -1,4 +1,4 @@ from .org_view import StaticView, InteractiveView, OrgWithKey -# TODO just rename org_view to init? - +from .org_view import Mirror, Queue +# TODO just rename org_view to init? diff --git a/src/orger/common.py b/src/orger/common.py index 8312aee..4e2c314 100644 --- a/src/orger/common.py +++ b/src/orger/common.py @@ -1,7 +1,11 @@ from datetime import datetime from typing import Optional -from .inorganic import OrgNode, timestamp +from .inorganic import OrgNode, timestamp, timestamp_with_style, TimestampStyle + + +class settings: + DEFAULT_TIMESTAMP_STYLE = TimestampStyle.INACTIVE def dt_heading(dt: Optional[datetime], heading: str): @@ -12,7 +16,7 @@ def dt_heading(dt: Optional[datetime], heading: str): if dt is None: return heading else: - return timestamp(dt, inactive=True) + ' ' + heading + return timestamp_with_style(dt=dt, style=settings.DEFAULT_TIMESTAMP_STYLE) + ' ' + heading def error(e: Exception) -> OrgNode: @@ -36,4 +40,5 @@ def todo(dt: datetime, **kwargs): ) +# todo use klogging2? from .klogging import LazyLogger, setup_logger diff --git a/src/orger/inorganic.py b/src/orger/inorganic.py index 717e72b..58cbdc1 100644 --- a/src/orger/inorganic.py +++ b/src/orger/inorganic.py @@ -5,40 +5,74 @@ import os from collections import OrderedDict from typing import NamedTuple, Optional, Sequence, Dict, Mapping, Any, Tuple, TypeVar, Callable, Union, List +import warnings + +# TODO settings object? not ideal... + +# todo use mypy literals later? +from enum import Enum +class TimestampStyle(Enum): + INACTIVE = ('[', ']') + ACTIVE = ('<', '>') + PLAIN = ('' , '') + NONE = () + Dateish = Union[datetime, date] -def link(*, url: str, title: Optional[str]) -> str: +PathIsh = Union[str, Path] + +def link(*, url: PathIsh, title: Optional[str]) -> str: """ >>> link(url='http://reddit.com', title='[R]eddit!') '[[http://reddit.com][Reddit!]]' """ - url = _sanitize_heading(url) + U = _sanitize_url(str(url)) if title == '': # org mode doesn't like empty titles.. # TODO sanitize_title? title = None if title is not None: title = _sanitize_heading(title) - return f'[[{url}][{title}]]' + return f'[[{U}][{title}]]' else: - return f'[[{url}]]' + return f'[[{U}]]' -def timestamp(t: Dateish, inactive: bool=False, active: bool=False) -> str: + +def timestamp(dt: Dateish, inactive: bool=False, active: bool=False) -> str: """ + default is active >>> dt = datetime.strptime('19920110 04:45', '%Y%m%d %H:%M') >>> timestamp(dt) - '1992-01-10 Fri 04:45' + '<1992-01-10 Fri 04:45>' + """ + style: TimestampStyle + if inactive ^ active: + warnings.warn(f"Please specify one of 'inactive' or 'active' for {dt}") + # arbitrary choice: we let active win. So the uses sees the offending entry on agenda + style = TimestampStyle.ACTIVE + elif inactive: + style = TimestampStyle.INACTIVE + else: # active + style = TimestampStyle.ACTIVE + return timestamp_with_style(dt=dt, style=style) + + +def timestamp_with_style(dt: Dateish, style: TimestampStyle) -> str: """ - beg, end = '', '' - if inactive: - beg, end = '[]' - if active: - beg, end = '<>' - r = asorgdate(t) - if isinstance(t, datetime): - r += " " + asorgtime(t) + >>> dt = datetime.max + >>> timestamp_with_style(dt, TimestampStyle.NONE) + '' + >>> timestamp_with_style(dt, TimestampStyle.PLAIN) + '9999-12-31 Fri 23:59' + """ + if style is TimestampStyle.NONE: + return '' + beg, end = style.value + r = asorgdate(dt) + if isinstance(dt, datetime): + r += " " + asorgtime(dt) return beg + r + end @@ -53,6 +87,7 @@ def asorgoutline( properties: Optional[Mapping[str, str]]=None, body: Optional[str] = None, level: int=1, + escaped: bool=False, ) -> str: r""" Renders Org mode outline (apart from children) @@ -82,10 +117,12 @@ def asorgoutline( """ if heading is None: heading = '' - heading = re.sub(r'\s', ' ', heading) + # TODO reuse sanitizing? + if not escaped: + heading = re.sub(r'\s', ' ', heading) # TODO not great that we always pad body I guess. maybe needs some sort of raw_body argument? - if body is not None: + if body is not None and not escaped: body = _sanitize_body(body) parts = [] @@ -147,6 +184,9 @@ class OrgNode(NamedTuple): body: Optional[str] = None children: Sequence[Any] = () # mypy wouldn't allow recursive type here... + # NOTE: this is a 'private' attribute + escaped: bool=False + def _render_self(self) -> str: return asorgoutline( heading=_from_lazy(self.heading), @@ -156,6 +196,7 @@ def _render_self(self) -> str: scheduled=self.scheduled, body=self.body, level=0, + escaped=self.escaped, ) def _render_hier(self) -> List[Tuple[int, str]]: @@ -202,6 +243,25 @@ def _from_lazy(x: Lazy[T]) -> T: return x +from typing import Mapping +def maketrans(d: Dict[str, str]) -> Dict[int, str]: + # make mypy happy... https://github.com/python/mypy/issues/4374 + return str.maketrans(d) + + +def _sanitize_url(x: str) -> str: + r''' + Sanitize url to put into [] safely + >>> _sanitize_url("/path/to/[Baez] Lectures on Classical Mechanics.pdf") + '/path/to/\\[Baez\\] Lectures on Classical Mechanics.pdf' + ''' + # might be incomplete.. + return x.translate(maketrans({ + '[': r'\[', + ']': r'\]', + })) + + def _sanitize_heading(x: str) -> str: # TODO do something smarter? e.g. https://stackoverflow.com/questions/12737564/escaping-characters-in-emacs-org-mode return re.sub(r'[\]\[]', '', x) diff --git a/src/orger/modules b/src/orger/modules new file mode 120000 index 0000000..8b0e854 --- /dev/null +++ b/src/orger/modules @@ -0,0 +1 @@ +../../modules \ No newline at end of file diff --git a/src/orger/org_view.py b/src/orger/org_view.py index 0b24fea..b38222a 100644 --- a/src/orger/org_view.py +++ b/src/orger/org_view.py @@ -8,8 +8,7 @@ from tempfile import TemporaryDirectory from typing import List, Tuple, Iterable, Optional -# TODO vendorize inorganic and state thing -from .inorganic import OrgNode +from .inorganic import OrgNode, TimestampStyle from .state import JsonState from .atomic_append import PathIsh, atomic_append_check, assert_not_edited from .common import setup_logger @@ -21,20 +20,32 @@ OrgWithKey = Tuple[Key, OrgNode] +_style_map = { + k.lower(): v + for k, v in TimestampStyle._member_map_.items() # type: ignore[attr-defined] +} + class OrgView: logger_tag: Optional[str] = None + DEFAULT_HEADER: str = '# should be overridden' + # TODO cmdline args shouldn't be none? def __init__( self, cmdline_args: Optional[Namespace]=None, file_header: Optional[str]=None, ) -> None: - self.cmdline_args = cmdline_args + self.cmdline_args: Namespace = cmdline_args if cmdline_args is not None else Namespace() tag = self.name() if self.logger_tag is None else self.logger_tag self.logger = logging.getLogger(tag) tool = Path(inspect.getfile(self.__class__)).absolute() - self.file_header = file_header if file_header is not None else f"# AUTOGENERATED BY {tool}\n" + self.file_header = file_header if file_header is not None else self.DEFAULT_HEADER.format(tool=tool) + + @property + def args(self) -> Namespace: + # TODO deprecate cmdline_args? + return self.cmdline_args @classmethod def name(cls): @@ -44,31 +55,55 @@ def get_items(self) -> Iterable: raise NotImplementedError def main_common(self) -> None: + timestamp_style = self.args.timestamps + from .common import settings + # hacky, but does the trick for now... + settings.DEFAULT_TIMESTAMP_STYLE = _style_map[timestamp_style] setup_logger(self.logger, level=logging.DEBUG) + @classmethod + def parser(cls) -> ArgumentParser: + p = ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + + p.add_argument( + '--timestamps', + type=str, + choices=list(_style_map.keys()), + default='inactive', + help="timestamp style, default '%(default)s'", + ) + return p + # TODO wonder if I could reuse append bits here? -class StaticView(OrgView): +class Mirror(OrgView): """ - =StaticView= are meant to be read only and are generated from scratch every time. + *Mirror* (old name =StaticView=): mirrors *all data* from a source, and generated from scratch every time, hence *read only*. """ + + DEFAULT_HEADER = ''' +# This file is AUTOGENERATED by {tool} +# It's deliberately read-only, because it will be overwritten next time Orger is run. +# If you want to edit it anyway, you can use chmod +w in your terminal, or M-x toggle-read-only in Emacs. +'''.lstrip() + + @classmethod def main(cls, setup_parser=None) -> None: - p = ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + p = cls.parser() p.add_argument('--to', type=Path, default=Path(cls.name() + '.org')) if setup_parser is not None: setup_parser(p) args = p.parse_args() inst = cls(cmdline_args=args) + inst.main_common() inst._run(to=args.to) def get_items(self) -> Iterable: raise NotImplementedError def _run(self, to: Path): - self.main_common() - org_tree = self.make_tree() rtree = org_tree.render(level=0) @@ -90,15 +125,15 @@ def make_tree(self) -> OrgNode: else: items.append(p[1]) # presumably, OrgWithKey - split = self.file_header.splitlines() - # TODO quite hacky.. - heading = split[0] - body = '\n'.join(split[1:]) + split = self.file_header.splitlines(keepends=True) + heading = split[0].rstrip() + body = ''.join(split[1:]) return OrgNode( # TODO shit. are newlines sanitized from file header?? heading=heading, body=body, children=items, + escaped=True, ) @classmethod @@ -121,14 +156,23 @@ def test(): if contains is not None: assert contains in ll.render() return test +StaticView = Mirror -class InteractiveView(OrgView): +class Queue(OrgView): """ - =InteractiveView= are incremental, so only new items from the data source are appended to the output org-mode file. + *Queue* (old name =InteractiveView=): works as a queue, *only previously unseen items* from the data source are appended to the output org-mode file. - To keep track of old/new items, it's using a separate JSON state file. + To keep track of old/new items, it's using a separate JSON =state= file. + + A typical usecase is a todo list, or a content processing queue. + You can use such a module as you use any other org-mode file: schedule/refile/comment/set priorities, etc. """ + + DEFAULT_HEADER = ''' +# This file is AUTOGENERATED by {tool} +'''.lstrip() + def _run( self, to: Path, @@ -175,7 +219,7 @@ def get_items(self) -> Iterable[OrgWithKey]: @classmethod def main(cls, setup_parser=None) -> None: - p = ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) + p = cls.parser() p.add_argument('--to' , type=Path, default=Path(cls.name() + '.org') , help='file where new items are appended') p.add_argument('--state', type=Path, default=Path(cls.name() + '.state.json'), help='state file for keeping track of handled items') p.add_argument('--init', action='store_true') @@ -184,7 +228,6 @@ def main(cls, setup_parser=None) -> None: setup_parser(p) args = p.parse_args() - inst = cls(cmdline_args=args) inst.main_common() inst._run( @@ -193,12 +236,13 @@ def main(cls, setup_parser=None) -> None: init=args.init, dry_run=args.dry_run, ) +InteractiveView = Queue def test_org_view_overwrite(tmp_path: Path): class TestView(StaticView): def __init__(self, items: List[OrgWithKey], *args, **kwargs) -> None: - super().__init__(*args, file_header='# autogenerated!\n', **kwargs) # type: ignore + super().__init__(*args, file_header='# autogenerated!\n#+TITLE: sometitle\nalso text\n', **kwargs) # type: ignore self.items = items def get_items(self): @@ -207,7 +251,11 @@ def get_items(self): rpath = tmp_path / 'test.org' TestView([])._run(to=rpath) - assert rpath.read_text() == '# autogenerated!\n' + assert rpath.read_text() == ''' +# autogenerated! +#+TITLE: sometitle +also text +'''.lstrip() TestView([ # TODO shit, it's gonna use implicit date?? @@ -217,6 +265,8 @@ def get_items(self): # TODO eh, perhaps use trailing space? assert rpath.read_text() == """ # autogenerated! +#+TITLE: sometitle +also text * whatever * alala""".lstrip() diff --git a/tox.ini b/tox.ini index 8a223e5..7dfe81f 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] minversion = 3.5 # relies on the correct version of Python installed -envlist = py3,pylint,mypy +envlist = py3,mypy [testenv] passenv = @@ -16,10 +16,3 @@ skip_install = true commands = pip install -e .[linting] python -m mypy src - - -[testenv:pylint] -skip_install = true -commands = - pip install -e .[linting] - python -m pylint -E src