From 0c5fd4fe7e8923d70588064e61e4af2878ab362d Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Thu, 30 Oct 2025 22:04:53 +0000 Subject: [PATCH 1/2] chore: bump all ci files/github actions/ruff up to date; enable ty type checking --- .ci/release-uv | 8 +- .github/workflows/main.yml | 54 +++++---- conftest.py | 15 ++- doc/source/conf.py | 51 +++++---- pyproject.toml | 10 +- ruff.toml | 64 +++-------- src/orgparse/__init__.py | 4 +- src/orgparse/date.py | 114 ++++++++++--------- src/orgparse/extra.py | 8 +- src/orgparse/inline.py | 7 +- src/orgparse/node.py | 89 ++++++++------- src/orgparse/tests/data/00_simple.py | 3 +- src/orgparse/tests/data/01_attributes.py | 6 +- src/orgparse/tests/data/02_tree_struct.py | 12 +- src/orgparse/tests/data/03_repeated_tasks.py | 2 +- src/orgparse/tests/data/04_logbook.py | 2 +- src/orgparse/tests/data/05_tags.py | 3 +- src/orgparse/tests/test_data.py | 41 +++---- src/orgparse/tests/test_date.py | 1 - src/orgparse/tests/test_hugedata.py | 7 +- src/orgparse/tests/test_misc.py | 66 ++++++----- src/orgparse/tests/test_rich.py | 6 +- tox.ini | 29 ++++- 23 files changed, 310 insertions(+), 292 deletions(-) diff --git a/.ci/release-uv b/.ci/release-uv index c56697c..4da39b7 100755 --- a/.ci/release-uv +++ b/.ci/release-uv @@ -26,6 +26,7 @@ from subprocess import check_call is_ci = os.environ.get('CI') is not None + def main() -> None: p = argparse.ArgumentParser() p.add_argument('--use-test-pypi', action='store_true') @@ -34,11 +35,7 @@ def main() -> None: publish_url = ['--publish-url', 'https://test.pypi.org/legacy/'] if args.use_test_pypi else [] root = Path(__file__).absolute().parent.parent - os.chdir(root) # just in case - - if is_ci: - # see https://github.com/actions/checkout/issues/217 - check_call('git fetch --prune --unshallow'.split()) + os.chdir(root) # just in case # TODO ok, for now uv won't remove dist dir if it already exists # https://github.com/astral-sh/uv/issues/10293 @@ -46,7 +43,6 @@ def main() -> None: if dist.exists(): shutil.rmtree(dist) - # todo what is --force-pep517? check_call(['uv', 'build']) if not is_ci: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3599e6a..e51bcf6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -6,10 +6,13 @@ on: branches: '*' tags: 'v[0-9]+.*' # only trigger on 'release' tags for PyPi # Ideally I would put this in the pypi job... but github syntax doesn't allow for regexes there :shrug: - pull_request: # needed to trigger on others' PRs + + # Needed to trigger on others' PRs. # Note that people who fork it need to go to "Actions" tab on their fork and click "I understand my workflows, go ahead and enable them". - workflow_dispatch: # needed to trigger workflows manually - # todo cron? + pull_request: + + # Needed to trigger workflows manually. + workflow_dispatch: inputs: debug_enabled: type: boolean @@ -24,7 +27,7 @@ jobs: fail-fast: false matrix: platform: [ubuntu-latest, macos-latest, windows-latest] - python-version: ['3.9', '3.10', '3.11', '3.12', '3.13'] + python-version: ['3.9', '3.10', '3.11', '3.12', '3.13', '3.14'] # vvv just an example of excluding stuff from matrix # exclude: [{platform: macos-latest, python-version: '3.6'}] @@ -37,16 +40,16 @@ jobs: # ugh https://github.com/actions/toolkit/blob/main/docs/commands.md#path-manipulation - run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: submodules: recursive fetch-depth: 0 # nicer to have all git history when debugging/for tests - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@v7 with: enable-cache: false # we don't have lock files, so can't use them as cache key @@ -55,44 +58,57 @@ jobs: # explicit bash command is necessary for Windows CI runner, otherwise it thinks it's cmd... - run: bash .ci/run + env: + # only compute lxml coverage on ubuntu; it crashes on windows + CI_MYPY_COVERAGE: ${{ matrix.platform == 'ubuntu-latest' && '--cobertura-xml-report .coverage.mypy' || '' }} - if: matrix.platform == 'ubuntu-latest' # no need to compute coverage for other platforms - uses: actions/upload-artifact@v4 + uses: codecov/codecov-action@v5 with: - include-hidden-files: true - name: .coverage.mypy_${{ matrix.platform }}_${{ matrix.python-version }} - path: .coverage.mypy/ + fail_ci_if_error: true # default false + token: ${{ secrets.CODECOV_TOKEN }} + flags: mypy-${{ matrix.python-version }} + files: .coverage.mypy/cobertura.xml pypi: - runs-on: ubuntu-latest + # Do not run it for PRs/cron schedule etc. + # NOTE: release tags are guarded by on: push: tags on the top. + if: github.event_name == 'push' && (startsWith(github.event.ref, 'refs/tags/') || (github.event.ref == format('refs/heads/{0}', github.event.repository.master_branch))) + # Ugh, I tried using matrix or something to explicitly generate only test pypi or prod pypi pipelines. + # But github actions is so shit, it's impossible to do any logic at all, e.g. doesn't support conditional matrix, if/else statements for variables etc. + needs: [build] # add all other jobs here + + runs-on: ubuntu-latest + permissions: # necessary for Trusted Publishing id-token: write + steps: # ugh https://github.com/actions/toolkit/blob/main/docs/commands.md#path-manipulation - run: echo "$HOME/.local/bin" >> $GITHUB_PATH - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 with: submodules: recursive + fetch-depth: 0 # pull all commits to correctly infer vcs version - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: '3.10' - - uses: astral-sh/setup-uv@v5 + - uses: astral-sh/setup-uv@v7 with: enable-cache: false # we don't have lock files, so can't use them as cache key - name: 'release to test pypi' # always deploy merged master to test pypi - if: github.event_name != 'pull_request' && github.event.ref == 'refs/heads/master' + if: github.event.ref == format('refs/heads/{0}', github.event.repository.master_branch) run: .ci/release-uv --use-test-pypi - - name: 'release to pypi' + - name: 'release to prod pypi' # always deploy tags to release pypi - # NOTE: release tags are guarded by on: push: tags on the top - if: github.event_name != 'pull_request' && startsWith(github.event.ref, 'refs/tags') + if: startsWith(github.event.ref, 'refs/tags/') run: .ci/release-uv diff --git a/conftest.py b/conftest.py index 91a43a3..627def8 100644 --- a/conftest.py +++ b/conftest.py @@ -20,6 +20,8 @@ # resolve_package_path is called from _pytest.pathlib.import_path # takes a full abs path to the test file and needs to return the path to the 'root' package on the filesystem resolve_pkg_path_orig = _pytest.pathlib.resolve_package_path + + def resolve_package_path(path: pathlib.Path) -> Optional[pathlib.Path]: result = path # search from the test file upwards for parent in result.parents: @@ -30,7 +32,12 @@ def resolve_package_path(path: pathlib.Path) -> Optional[pathlib.Path]: if path.name == 'conftest.py': return resolve_pkg_path_orig(path) raise RuntimeError("Couldn't determine path for ", path) -_pytest.pathlib.resolve_package_path = resolve_package_path + + +# NOTE: seems like it's not necessary anymore? +# keeping it for now just in case +# after https://github.com/pytest-dev/pytest/pull/13426 we should be able to remove the whole conftest +# _pytest.pathlib.resolve_package_path = resolve_package_path # without patching, the orig function returns just a package name for some reason @@ -38,10 +45,14 @@ def resolve_package_path(path: pathlib.Path) -> Optional[pathlib.Path]: # so we need to point it at the absolute path properly # not sure what are the consequences.. maybe it wouldn't be able to run against installed packages? not sure.. search_pypath_orig = _pytest.main.search_pypath + + def search_pypath(module_name: str) -> str: mpath = root_dir / module_name.replace('.', os.sep) if not mpath.is_dir(): mpath = mpath.with_suffix('.py') assert mpath.exists(), mpath # just in case return str(mpath) -_pytest.main.search_pypath = search_pypath + + +_pytest.main.search_pypath = search_pypath # ty: ignore[invalid-assignment] diff --git a/doc/source/conf.py b/doc/source/conf.py index 3f90daa..1e451e7 100644 --- a/doc/source/conf.py +++ b/doc/source/conf.py @@ -1,8 +1,7 @@ -# -*- coding: utf-8 -*- - -from os.path import dirname import sys -sys.path.insert(0, dirname(dirname(dirname(__file__)))) +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) # -- General configuration ------------------------------------------------ extensions = [ @@ -19,14 +18,14 @@ import orgparse # General information about the project. -project = u'orgparse' -copyright = u'2012, Takafumi Arakaki' +project = 'orgparse' +copyright = '2012, Takafumi Arakaki' # noqa: A001 # The short X.Y version. # TODO use setup.py for version -version = orgparse.__version__ +version = orgparse.__version__ # ty: ignore[unresolved-attribute] # The full version, including alpha/beta/rc tags. -release = orgparse.__version__ +release = orgparse.__version__ # ty: ignore[unresolved-attribute] exclude_patterns = [] @@ -43,22 +42,19 @@ # -- Options for LaTeX output --------------------------------------------- latex_elements = { -# The paper size ('letterpaper' or 'a4paper'). -#'papersize': 'letterpaper', - -# The font size ('10pt', '11pt' or '12pt'). -#'pointsize': '10pt', - -# Additional stuff for the LaTeX preamble. -#'preamble': '', + # The paper size ('letterpaper' or 'a4paper'). + #'papersize': 'letterpaper', + # The font size ('10pt', '11pt' or '12pt'). + #'pointsize': '10pt', + # Additional stuff for the LaTeX preamble. + #'preamble': '', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto/manual]). latex_documents = [ - ('index', 'orgparse.tex', u'orgparse Documentation', - u'Takafumi Arakaki', 'manual'), + ('index', 'orgparse.tex', 'orgparse Documentation', 'Takafumi Arakaki', 'manual'), ] @@ -66,12 +62,11 @@ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - ('index', 'orgparse', u'orgparse Documentation', - [u'Takafumi Arakaki'], 1) + ('index', 'orgparse', 'orgparse Documentation', ['Takafumi Arakaki'], 1), ] # If true, show URL addresses after external links. -#man_show_urls = False +# man_show_urls = False # -- Options for Texinfo output ------------------------------------------- @@ -79,9 +74,15 @@ # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ - ('index', 'orgparse', u'orgparse Documentation', - u'Takafumi Arakaki', 'orgparse', 'One line description of project.', - 'Miscellaneous'), + ( + 'index', + 'orgparse', + 'orgparse Documentation', + 'Takafumi Arakaki', + 'orgparse', + 'One line description of project.', + 'Miscellaneous', + ), ] @@ -93,4 +94,4 @@ autodoc_member_order = 'bysource' autodoc_default_flags = ['members'] -inheritance_graph_attrs = dict(rankdir="TB") +inheritance_graph_attrs = {'rankdir': "TB"} diff --git a/pyproject.toml b/pyproject.toml index 0ae6698..0aa9ba0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,17 +32,11 @@ testing = [ "pytest", "ruff", "mypy", - "lxml", # for mypy html coverage + "lxml", # for mypy html coverage + "ty>=0.0.1a25", ] - -# workaround for error during uv publishing -# see https://github.com/astral-sh/uv/issues/9513#issuecomment-2519527822 -[tool.setuptools] -license-files = [] - - [build-system] requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" diff --git a/ruff.toml b/ruff.toml index 0fa381a..e05c3b4 100644 --- a/ruff.toml +++ b/ruff.toml @@ -1,37 +1,7 @@ +line-length = 120 # impacts import sorting + lint.extend-select = [ - "F", # flakes rules -- default, but extend just in case - "E", # pycodestyle -- default, but extend just in case - "W", # various warnings - - "B", # 'bugbear' set -- various possible bugs - "C4", # flake8-comprehensions -- unnecessary list/map/dict calls - "COM", # trailing commas - "EXE", # various checks wrt executable files - "I", # sort imports - "ICN", # various import conventions - "FBT", # detect use of boolean arguments - "FURB", # various rules - "PERF", # various potential performance speedups - "PD", # pandas rules - "PIE", # 'misc' lints - "PLC", # pylint convention rules - "PLR", # pylint refactor rules - "PLW", # pylint warnings - "PT", # pytest stuff - "PYI", # various type hinting rules - "RET", # early returns - "RUF", # various ruff-specific rules - "TID", # various imports suggestions - "TRY", # various exception handling rules - "UP", # detect deprecated python stdlib stuff - "FA", # suggest using from __future__ import annotations - # "PTH", # pathlib migration # FIXME do later.. a bit overwhelming - "ARG", # unused argument checks - "A", # builtin shadowing - "G", # logging stuff - # "EM", # TODO hmm could be helpful to prevent duplicate err msg in traceback.. but kinda annoying - - # "ALL", # uncomment this to check for new rules! + "ALL", ] # Preserve types, even if a file imports `from __future__ import annotations` @@ -48,10 +18,10 @@ lint.ignore = [ "FIX", # complains about fixmes/todos -- annoying "TD", # complains about todo formatting -- too annoying "ANN", # missing type annotations? seems way to strict though + "EM" , # suggests assigning all exception messages into a variable first... pretty annoying ### too opinionated style checks "E501", # too long lines - "E702", # Multiple statements on one line (semicolon) "E731", # assigning lambda instead of using def "E741", # Ambiguous variable name: `l` "E742", # Ambiguous class name: `O @@ -66,9 +36,6 @@ lint.ignore = [ ## might be nice .. but later and I don't wanna make it strict "E402", # Module level import not at top of file - "RUF100", # unused noqa -- handle later - "RUF012", # mutable class attrs should be annotated with ClassVar... ugh pretty annoying for user configs - ### these are just nitpicky, we usually know better "PLR0911", # too many return statements "PLR0912", # too many branches @@ -83,10 +50,8 @@ lint.ignore = [ "B009", # calling gettattr with constant attribute -- this is useful to convince mypy "B010", # same as above, but setattr - "B011", # complains about assert False "B017", # pytest.raises(Exception) "B023", # seems to result in false positives? - "B028", # suggest using explicit stacklevel? TODO double check later, but not sure it's useful # complains about useless pass, but has sort of a false positive if the function has a docstring? # this is common for click entrypoints (e.g. in __main__), so disable @@ -106,27 +71,19 @@ lint.ignore = [ "PLW0603", # global variable update.. we usually know why we are doing this "PLW2901", # for loop variable overwritten, usually this is intentional - "PT011", # pytest raises should is too broad - "PT012", # pytest raises should contain a single statement + "PT011", # pytest raises is too broad "COM812", # trailing comma missing -- mostly just being annoying with long multiline strings - "PD901", # generic variable name df - "TRY003", # suggests defining exception messages in exception class -- kinda annoying - "TRY004", # prefer TypeError -- don't see the point "TRY201", # raise without specifying exception name -- sometimes hurts readability - "TRY400", # TODO double check this, might be useful + "TRY400", # a bit dumb, and results in false positives (see https://github.com/astral-sh/ruff/issues/18070) "TRY401", # redundant exception in logging.exception call? TODO double check, might result in excessive logging - "PGH", # TODO force error code in mypy instead? although it also has blanket noqa rule - "TID252", # Prefer absolute imports over relative imports from parent modules - "UP038", # suggests using | (union) in isisntance checks.. but it results in slower code - ## too annoying - "T20", # just complains about prints and pprints + "T20", # just complains about prints and pprints (TODO maybe consider later?) "Q", # flake quotes, too annoying "C90", # some complexity checking "G004", # logging statement uses f string @@ -134,7 +91,12 @@ lint.ignore = [ "SLF001", # private member accessed "BLE001", # do not catch 'blind' Exception "INP001", # complains about implicit namespace packages - "SIM", # some if statements crap + "SIM102", # if statements collapsing, often hurts readability + "SIM103", # multiple conditions collapsing, often hurts readability + "SIM105", # suggests using contextlib.suppress instad of try/except -- this wouldn't be mypy friendly + "SIM108", # suggests using ternary operation instead of if -- hurts readability + "SIM110", # suggests using any(...) instead of for look/return -- hurts readability + "SIM117", # suggests using single with statement instead of nested -- doesn't work in tests "RSE102", # complains about missing parens in exceptions ## ] diff --git a/src/orgparse/__init__.py b/src/orgparse/__init__.py index d699f4f..110c474 100644 --- a/src/orgparse/__init__.py +++ b/src/orgparse/__init__.py @@ -145,7 +145,7 @@ def load(path: Union[str, Path, TextIO], env: Optional[OrgEnv] = None) -> OrgNod return loadi(all_lines, filename=filename, env=env) -def loads(string: str, filename: str='', env: Optional[OrgEnv]=None) -> OrgNode: +def loads(string: str, filename: str = '', env: Optional[OrgEnv] = None) -> OrgNode: """ Load org-mode document from a string. @@ -155,7 +155,7 @@ def loads(string: str, filename: str='', env: Optional[OrgEnv]=None) -> return loadi(string.splitlines(), filename=filename, env=env) -def loadi(lines: Iterable[str], filename: str='', env: Optional[OrgEnv]=None) -> OrgNode: +def loadi(lines: Iterable[str], filename: str = '', env: Optional[OrgEnv] = None) -> OrgNode: """ Load org-mode document from an iterative object. diff --git a/src/orgparse/date.py b/src/orgparse/date.py index ccaa5c3..1685f32 100644 --- a/src/orgparse/date.py +++ b/src/orgparse/date.py @@ -10,8 +10,7 @@ def total_seconds(td: timedelta) -> float: """Equivalent to `datetime.timedelta.total_seconds`.""" - return float(td.microseconds + - (td.seconds + td.days * 24 * 3600) * 10 ** 6) / 10 ** 6 + return float(td.microseconds + (td.seconds + td.days * 24 * 3600) * 10**6) / 10**6 def total_minutes(td: timedelta) -> float: @@ -126,13 +125,13 @@ def gene_timestamp_regex(brtype: str, prefix: str | None = None, *, nocookie: bo (?P<{prefix}warndwmy> [hdwmy]) )? """ - # http://www.pythonregex.com/ regex = ''.join([ bo, regex_date_time, regex_cookie if nocookie or brtype != 'nobrace' else '', '({ignore}*?)', - bc]) + bc, + ]) # fmt: skip return regex.format(prefix=prefix, ignore=ignore) @@ -155,24 +154,27 @@ def is_same_day(date0, date1) -> bool: """ Check if two dates or datetimes are on the same day """ - return (OrgDate._date_to_tuple(date0)[:3] == OrgDate._date_to_tuple(date1)[:3]) + return OrgDate._date_to_tuple(date0)[:3] == OrgDate._date_to_tuple(date1)[:3] TIMESTAMP_NOBRACE_RE = re.compile( gene_timestamp_regex('nobrace', prefix=''), - re.VERBOSE) + re.VERBOSE, +) TIMESTAMP_RE = re.compile( - '|'.join((gene_timestamp_regex('active'), - gene_timestamp_regex('inactive'))), - re.VERBOSE) + '|'.join(( + gene_timestamp_regex('active'), + gene_timestamp_regex('inactive'), + )), + re.VERBOSE, +) # fmt: skip _Repeater = tuple[str, int, str] class OrgDate: - _active_default = True """ The default active value. @@ -194,7 +196,7 @@ def __init__( self, start, end=None, - active: bool | None = None, + active: bool | None = None, # noqa: FBT001 repeater: _Repeater | None = None, warning: _Repeater | None = None, ) -> None: @@ -255,7 +257,7 @@ def _to_date(date) -> DateIsh: "Automatic conversion to the datetime object " "requires at least 3 elements in the tuple. " f"Only {len(date)} elements are in the given tuple '{date}'." - ) + ) elif isinstance(date, (int, float)): return datetime.datetime.fromtimestamp(date) else: @@ -268,7 +270,7 @@ def _date_to_tuple(date: DateIsh) -> tuple[int, ...]: elif isinstance(date, datetime.date): return tuple(date.timetuple()[:3]) else: - raise RuntimeError(f"can't happen: {date}") + raise TypeError(f"can't happen: {date} {type(date)}") def __repr__(self) -> str: args = [ @@ -312,15 +314,18 @@ def __str__(self) -> str: def __bool__(self) -> bool: return bool(self._start) + def __hash__(self) -> int: + return hash((self._start, self._end, self._active, self._repeater, self._warning)) + def __eq__(self, other) -> bool: - if (isinstance(other, OrgDate) and - self._start is None and - other._start is None): + if isinstance(other, OrgDate) and self._start is None and other._start is None: return True - return (isinstance(other, self.__class__) and - self._start == other._start and - self._end == other._end and - self._active == other._active) + return ( + isinstance(other, self.__class__) + and self._start == other._start + and self._end == other._end + and self._active == other._active + ) @property def start(self) -> DateIsh: @@ -390,11 +395,10 @@ def has_overlap(self, other) -> bool: if not isinstance(other, OrgDate): other = OrgDate(other) if self.has_end(): - return (self._datetime_in_range(other.start) or - self._datetime_in_range(other.end)) + return self._datetime_in_range(other.start) or self._datetime_in_range(other.end) elif other.has_end(): return other._datetime_in_range(self.start) - elif self.start == other.get_start: + elif self.start == other.start: return True else: return False @@ -419,11 +423,11 @@ def _as_datetime(date) -> datetime.datetime: @staticmethod def _daterange_from_groupdict(dct, prefix='') -> tuple[list, Optional[list]]: - start_keys = ['year', 'month', 'day', 'hour' , 'min'] - end_keys = ['year', 'month', 'day', 'end_hour', 'end_min'] + start_keys = ['year', 'month', 'day', 'hour' , 'min'] # fmt: skip + end_keys = ['year', 'month', 'day', 'end_hour', 'end_min'] # fmt: skip start_range = list(map(int, filter(None, (dct[prefix + k] for k in start_keys)))) end_range: Optional[list] - end_range = list(map(int, filter(None, (dct[prefix + k] for k in end_keys)))) + end_range = list(map(int, filter(None, (dct[prefix + k] for k in end_keys)))) if len(end_range) < len(end_keys): end_range = None return (start_range, end_range) @@ -451,7 +455,7 @@ def list_from_str(cls, string: str) -> list[OrgDate]: cookie_suffix = ['pre', 'num', 'dwmy'] match = TIMESTAMP_RE.search(string) if match: - rest = string[match.end():] + rest = string[match.end() :] mdict = match.groupdict() if mdict['active_year']: prefix = 'active_' @@ -474,17 +478,20 @@ def list_from_str(cls, string: str) -> list[OrgDate]: has_rangedash = rest.startswith(rangedash) match2 = TIMESTAMP_RE.search(rest) if has_rangedash else None if has_rangedash and match2: - rest = rest[match2.end():] + rest = rest[match2.end() :] # no need for check activeness here because of the rangedash mdict2 = match2.groupdict() odate = cls( cls._datetuple_from_groupdict(mdict, prefix), cls._datetuple_from_groupdict(mdict2, prefix), - active=active, repeater=repeater, warning=warning) + active=active, + repeater=repeater, + warning=warning, + ) else: odate = cls( - *cls._daterange_from_groupdict(mdict, prefix), - active=active, repeater=repeater, warning=warning) + *cls._daterange_from_groupdict(mdict, prefix), active=active, repeater=repeater, warning=warning + ) return [odate, *cls.list_from_str(rest)] else: return [] @@ -503,8 +510,7 @@ def from_str(cls, string: str) -> OrgDate: match = cls._from_str_re.match(string) if match: mdict = match.groupdict() - return cls(cls._datetuple_from_groupdict(mdict), - active=cls._active_default) + return cls(cls._datetuple_from_groupdict(mdict), active=cls._active_default) else: return cls(None) @@ -516,12 +522,13 @@ def compile_sdc_re(sdctype): return re.compile( r'^(?!\#).*{}:\s+{}'.format( sdctype, - gene_timestamp_regex(brtype, prefix='', nocookie=True)), - re.VERBOSE) + gene_timestamp_regex(brtype, prefix='', nocookie=True), + ), + re.VERBOSE, + ) class OrgDateSDCBase(OrgDate): - _re = None # override this! # FIXME: use OrgDate.from_str @@ -535,7 +542,7 @@ def from_str(cls, string): start = cls._datetuple_from_groupdict(mdict) end = None end_hour = mdict['end_hour'] - end_min = mdict['end_min'] + end_min = mdict['end_min'] if end_hour is not None and end_min is not None: end_dict = {} end_dict.update(mdict) @@ -553,38 +560,37 @@ def from_str(cls, string): keys = [prefix + 'warn' + suffix for suffix in cookie_suffix] values = [mdict[k] for k in keys] warning = (values[0], int(values[1]), values[2]) - return cls(start, end, active=cls._active_default, - repeater=repeater, warning=warning) + return cls(start, end, active=cls._active_default, repeater=repeater, warning=warning) else: return cls(None) class OrgDateScheduled(OrgDateSDCBase): """Date object to represent SCHEDULED attribute.""" + _re = compile_sdc_re('SCHEDULED') _active_default = True class OrgDateDeadline(OrgDateSDCBase): """Date object to represent DEADLINE attribute.""" + _re = compile_sdc_re('DEADLINE') _active_default = True class OrgDateClosed(OrgDateSDCBase): """Date object to represent CLOSED attribute.""" + _re = compile_sdc_re('CLOSED') _active_default = False def parse_sdc(string): - return (OrgDateScheduled.from_str(string), - OrgDateDeadline.from_str(string), - OrgDateClosed.from_str(string)) + return (OrgDateScheduled.from_str(string), OrgDateDeadline.from_str(string), OrgDateClosed.from_str(string)) class OrgDateClock(OrgDate): - """ Date object to represent CLOCK attributes. @@ -635,8 +641,7 @@ def is_duration_consistent(self): False """ - return (self._duration is None or - self._duration == total_minutes(self.duration)) + return self._duration is None or self._duration == total_minutes(self.duration) @classmethod def from_str(cls, line: str) -> OrgDateClock: @@ -677,11 +682,10 @@ def from_str(cls, line: str) -> OrgDateClock: r'^(?!#).*CLOCK:\s+' r'\[(\d+)\-(\d+)\-(\d+)[^\]\d]*(\d+)\:(\d+)\]' r'(--\[(\d+)\-(\d+)\-(\d+)[^\]\d]*(\d+)\:(\d+)\]\s+=>\s+(\d+)\:(\d+))?' - ) + ) class OrgDateRepeatedTask(OrgDate): - """ Date object to represent repeated tasks. """ @@ -697,14 +701,18 @@ def __repr__(self) -> str: args: list = [self._date_to_tuple(self.start), self.before, self.after] if self._active is not self._active_default: args.append(self._active) - return '{}({})'.format( - self.__class__.__name__, ', '.join(map(repr, args))) + return '{}({})'.format(self.__class__.__name__, ', '.join(map(repr, args))) + + def __hash__(self) -> int: + return hash((self._before, self._after)) def __eq__(self, other) -> bool: - return super().__eq__(other) and \ - isinstance(other, self.__class__) and \ - self._before == other._before and \ - self._after == other._after + return ( + super().__eq__(other) + and isinstance(other, self.__class__) + and self._before == other._before + and self._after == other._after + ) @property def before(self) -> str: diff --git a/src/orgparse/extra.py b/src/orgparse/extra.py index c720200..e89343e 100644 --- a/src/orgparse/extra.py +++ b/src/orgparse/extra.py @@ -11,6 +11,7 @@ Row = Sequence[str] + class Table: def __init__(self, lines: list[str]) -> None: self._lines = lines @@ -82,6 +83,8 @@ class Gap: Rich = Union[Table, Gap] + + def to_rich_text(text: str) -> Iterator[Rich]: ''' Convert an org-mode text into a 'rich' text, e.g. tables/lists/etc, interleaved by gaps. @@ -93,12 +96,13 @@ def to_rich_text(text: str) -> Iterator[Rich]: lines = text.splitlines(keepends=True) group: list[str] = [] last: type[Rich] = Gap + def emit() -> Rich: nonlocal group, last - if last is Gap: + if last is Gap: res = Gap() elif last is Table: - res = Table(group) # type: ignore[assignment] + res = Table(group) # type: ignore[assignment] else: raise RuntimeError(f'Unexpected type {last}') group = [] diff --git a/src/orgparse/inline.py b/src/orgparse/inline.py index 043c99d..a2057fc 100644 --- a/src/orgparse/inline.py +++ b/src/orgparse/inline.py @@ -25,9 +25,7 @@ def to_plain_text(org_text): See also: info:org#Link format """ - return RE_LINK.sub( - lambda m: m.group('desc0') or m.group('desc1'), - org_text) + return RE_LINK.sub(lambda m: m.group('desc0') or m.group('desc1'), org_text) RE_LINK = re.compile( @@ -45,4 +43,5 @@ def to_plain_text(org_text): \] \] ) """, - re.VERBOSE) + re.VERBOSE, +) diff --git a/src/orgparse/node.py b/src/orgparse/node.py index f6044a2..5794b43 100644 --- a/src/orgparse/node.py +++ b/src/orgparse/node.py @@ -32,6 +32,7 @@ def lines_to_chunks(lines: Iterable[str]) -> Iterable[list[str]]: chunk.append(l) yield chunk + RE_NODE_HEADER = re.compile(r"^\*+ ") @@ -53,6 +54,7 @@ def parse_heading_level(heading: str) -> tuple[str, int] | None: return (m.group(2), len(m.group(1))) return None + RE_HEADING_STARS = re.compile(r'^(\*+)\s+(.*?)\s*$') @@ -87,6 +89,7 @@ def parse_heading_tags(heading: str) -> tuple[str, list[str]]: tags = [] return (heading, tags) + # Tags are normal words containing letters, numbers, '_', and '@'. https://orgmode.org/manual/Tags.html RE_HEADING_TAGS = re.compile(r'(.*?)\s*:([\w@:]+):\s*$') @@ -106,7 +109,7 @@ def parse_heading_todos(heading: str, todo_candidates: list[str]) -> tuple[str, if heading == todo: return ('', todo) if heading.startswith(todo + ' '): - return (heading[len(todo) + 1:], todo) + return (heading[len(todo) + 1 :], todo) return (heading, None) @@ -130,9 +133,12 @@ def parse_heading_priority(heading): else: return (heading, None) + RE_HEADING_PRIORITY = re.compile(r'^\s*\[#([A-Z0-9])\] ?(.*)$') PropertyValue = Union[str, int, float] + + def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: """ Get property from given string. @@ -153,8 +159,10 @@ def parse_property(line: str) -> tuple[Optional[str], Optional[PropertyValue]]: prop_val = parse_duration_to_minutes(prop_val) return (prop_key, prop_val) + RE_PROP = re.compile(r'^\s*:(.*?):\s*(.*?)\s*$') + def parse_duration_to_minutes(duration: str) -> Union[float, int]: """ Parse duration minutes from given string. @@ -185,6 +193,7 @@ def parse_duration_to_minutes(duration: str) -> Union[float, int]: minutes = parse_duration_to_minutes_float(duration) return int(minutes) if minutes.is_integer() else minutes + def parse_duration_to_minutes_float(duration: str) -> float: """ Parse duration minutes from given string. @@ -238,6 +247,7 @@ def parse_duration_to_minutes_float(duration: str) -> float: return float(duration) raise ValueError(f"Invalid duration format {duration}") + # Conversion factor to minutes for a duration. ORG_DURATION_UNITS = { "min": 1, @@ -272,7 +282,9 @@ def parse_duration_to_minutes_float(duration: str) -> float: # Regexp matching float numbers. RE_FLOAT = re.compile(r'[0-9]+([.][0-9]*)?') -def parse_comment(line: str): # -> Optional[Tuple[str, Sequence[str]]]: # todo wtf?? it says 'ABCMeta isn't subscriptable??' + +# -> Optional[Tuple[str, Sequence[str]]]: # todo wtf?? it says 'ABCMeta isn't subscriptable??' +def parse_comment(line: str): """ Parse special comment such as ``#+SEQ_TODO`` @@ -288,7 +300,7 @@ def parse_comment(line: str): # -> Optional[Tuple[str, Sequence[str]]]: # todo end = match.end(0) comment = line[end:].split(':', maxsplit=1) if len(comment) >= 2: - key = comment[0] + key = comment[0] value = comment[1].strip() if key.upper() == 'FILETAGS': # just legacy behaviour; it seems like filetags is the only one that separated by ':' @@ -324,12 +336,13 @@ def parse_seq_todo(line): else: (todos, dones) = (line, '') strip_fast_access_key = lambda x: x.split('(', 1)[0] - return (list(map(strip_fast_access_key, todos.split())), - list(map(strip_fast_access_key, dones.split()))) + return ( + list(map(strip_fast_access_key, todos.split())), + list(map(strip_fast_access_key, dones.split())), + ) class OrgEnv: - """ Information global to the file (e.g, TODO keywords). """ @@ -435,7 +448,6 @@ def from_chunks(self, chunks): class OrgBaseNode(Sequence): - """ Base class for :class:`OrgRootNode` and :class:`OrgNode` @@ -495,12 +507,12 @@ class OrgBaseNode(Sequence): 5 """ - _body_lines: list[str] # set by the child classes + _body_lines: list[str] # set by the child classes def __init__(self, env: OrgEnv, index: int | None = None) -> None: self.env = env - self.linenumber = cast(int, None) # set in parse_lines + self.linenumber = cast(int, None) # set in parse_lines # content self._lines: list[str] = [] @@ -527,7 +539,7 @@ def __init__(self, env: OrgEnv, index: int | None = None) -> None: def __iter__(self): yield self level = self.level - for node in self.env._nodes[self._index + 1:]: + for node in self.env._nodes[self._index + 1 :]: if node.level > level: yield node else: @@ -547,13 +559,12 @@ def __getitem__(self, key): elif isinstance(key, int): if key < 0: key += len(self) - for (i, node) in enumerate(self): + for i, node in enumerate(self): if i == key: return node raise IndexError(f"Out of range {key}") else: - raise TypeError(f"Inappropriate type {type(key)} for {type(self)}" - ) + raise TypeError(f"Inappropriate type {type(key)} for {type(self)}") # tree structure @@ -585,7 +596,7 @@ def previous_same_level(self) -> OrgBaseNode | None: True """ - return self._find_same_level(reversed(self.env._nodes[:self._index])) + return self._find_same_level(reversed(self.env._nodes[: self._index])) @property def next_same_level(self) -> OrgBaseNode | None: @@ -607,11 +618,11 @@ def next_same_level(self) -> OrgBaseNode | None: True """ - return self._find_same_level(self.env._nodes[self._index + 1:]) + return self._find_same_level(self.env._nodes[self._index + 1 :]) # FIXME: cache parent node def _find_parent(self): - for node in reversed(self.env._nodes[:self._index]): + for node in reversed(self.env._nodes[: self._index]): if node.level < self.level: return node return None @@ -702,7 +713,7 @@ def parent(self): # FIXME: cache children nodes def _find_children(self): - nodeiter = iter(self.env._nodes[self._index + 1:]) + nodeiter = iter(self.env._nodes[self._index + 1 :]) try: node = next(nodeiter) except StopIteration: @@ -811,7 +822,7 @@ def _parse_comments(self): parsed = parse_comment(line) if parsed: (key, vals) = parsed - key = key.upper() # case insensitive, so keep as uppercase + key = key.upper() # case insensitive, so keep as uppercase special_comments.setdefault(key, []).extend(vals) self._special_comments = special_comments # parse TODO keys and store in OrgEnv @@ -905,8 +916,7 @@ def get_body(self, format: str = 'plain') -> str: # noqa: A002 See also: :meth:`get_heading`. """ - return self._get_text( - '\n'.join(self._body_lines), format) if self._lines else '' + return self._get_text('\n'.join(self._body_lines), format) if self._lines else '' @property def body(self) -> str: @@ -916,7 +926,7 @@ def body(self) -> str: @property def body_rich(self) -> Iterator[Rich]: r = self.get_body(format='rich') - return cast(Iterator[Rich], r) # meh.. + return cast(Iterator[Rich], r) # meh.. @property def heading(self) -> str: @@ -990,11 +1000,13 @@ def get_timestamps(self, active=False, inactive=False, range=False, point=False) """ return [ - ts for ts in self._timestamps if - (((active and ts.is_active()) or - (inactive and not ts.is_active())) and - ((range and ts.has_end()) or - (point and not ts.has_end())))] + ts + for ts in self._timestamps + if ( + ((active and ts.is_active()) or (inactive and not ts.is_active())) + and ((range and ts.has_end()) or (point and not ts.has_end())) + ) + ] @property def datelist(self): @@ -1067,7 +1079,6 @@ def get_file_property(self, property: str): # noqa: A002 class OrgRootNode(OrgBaseNode): - """ Node to represent a file. Its body contains all lines before the first headline @@ -1110,7 +1121,6 @@ def _iparse_timestamps(self, ilines: Iterator[str]) -> Iterator[str]: class OrgNode(OrgBaseNode): - """ Node to represent normal org node @@ -1141,7 +1151,7 @@ def _parse_pre(self): # FIXME: make the following parsers "lazy" ilines: Iterator[str] = iter(self._lines) try: - next(ilines) # skip heading + next(ilines) # skip heading except StopIteration: return ilines = self._iparse_sdc(ilines) @@ -1180,9 +1190,7 @@ def _iparse_sdc(self, ilines: Iterator[str]) -> Iterator[str]: return (self._scheduled, self._deadline, self._closed) = parse_sdc(line) - if not (self._scheduled or - self._deadline or - self._closed): + if not (self._scheduled or self._deadline or self._closed): yield line # when none of them were found for line in ilines: @@ -1214,8 +1222,7 @@ def _iparse_repeated_tasks(self, ilines: Iterator[str]) -> Iterator[str]: done_state = mdict['done'] todo_state = mdict['todo'] date = OrgDate.from_str(mdict['date']) - self._repeated_tasks.append( - OrgDateRepeatedTask(date.start, todo_state, done_state)) + self._repeated_tasks.append(OrgDateRepeatedTask(date.start, todo_state, done_state)) else: yield line @@ -1225,9 +1232,10 @@ def _iparse_repeated_tasks(self, ilines: Iterator[str]) -> Iterator[str]: State \s+ "(?P [^"]+)" \s+ from \s+ "(?P [^"]+)" \s+ \[ (?P [^\]]+) \]''', - re.VERBOSE) + re.VERBOSE, + ) - def get_heading(self, format: str ='plain') -> str: # noqa: A002 + def get_heading(self, format: str = 'plain') -> str: # noqa: A002 """ Return a string of head text without tags and TODO keywords. @@ -1390,10 +1398,7 @@ def has_date(self): """ Return ``True`` if it has any kind of timestamp """ - return (self.scheduled or - self.deadline or - self.datelist or - self.rangelist) + return self.scheduled or self.deadline or self.datelist or self.rangelist @property def repeated_tasks(self): @@ -1454,14 +1459,14 @@ def parse_lines(lines: Iterable[str], filename, env=None) -> OrgNode: nodes = env.from_chunks(ch2) nodelist = [] for lineno, node in zip(linenos, nodes): - lineno += 1 # in text editors lines are 1-indexed + lineno += 1 # in text editors lines are 1-indexed node.linenumber = lineno nodelist.append(node) # parse headings (level, TODO, TAGs, and heading) nodelist[0]._index = 0 # parse the root node nodelist[0]._parse_pre() - for (i, node) in enumerate(nodelist[1:], 1): # nodes except root node + for i, node in enumerate(nodelist[1:], 1): # nodes except root node node._index = i node._parse_pre() env._nodes = nodelist diff --git a/src/orgparse/tests/data/00_simple.py b/src/orgparse/tests/data/00_simple.py index d2de087..23ad86c 100644 --- a/src/orgparse/tests/data/00_simple.py +++ b/src/orgparse/tests/data/00_simple.py @@ -34,4 +34,5 @@ def tags(nums) -> set[str]: [4, None , tags([1]) , tags([1, 2]) ], [2, None , tags([]) , tags([2]) ], [1], - ])] + ]) +] # fmt: skip diff --git a/src/orgparse/tests/data/01_attributes.py b/src/orgparse/tests/data/01_attributes.py index df720fc..467df02 100644 --- a/src/orgparse/tests/data/01_attributes.py +++ b/src/orgparse/tests/data/01_attributes.py @@ -19,19 +19,19 @@ "clock": [ OrgDateClock((2010, 8, 8, 17, 40), (2010, 8, 8, 17, 50), 10), OrgDateClock((2010, 8, 8, 17, 00), (2010, 8, 8, 17, 30), 30), - ], + ], "properties": {"Effort": 70}, "datelist": [OrgDate((2010, 8, 16))], "rangelist": [ OrgDate((2010, 8, 7), (2010, 8, 8)), OrgDate((2010, 8, 9, 0, 30), (2010, 8, 10, 13, 20)), OrgDate((2019, 8, 10, 16, 30, 0), (2019, 8, 10, 17, 30, 0)), - ], + ], "body": """\ - <2010-08-16 Mon> DateList - <2010-08-07 Sat>--<2010-08-08 Sun> - <2010-08-09 Mon 00:30>--<2010-08-10 Tue 13:20> RangeList - - <2019-08-10 Sat 16:30-17:30> TimeRange""" + - <2019-08-10 Sat 16:30-17:30> TimeRange""", } node2: Raw = { diff --git a/src/orgparse/tests/data/02_tree_struct.py b/src/orgparse/tests/data/02_tree_struct.py index a4ef46c..86b6314 100644 --- a/src/orgparse/tests/data/02_tree_struct.py +++ b/src/orgparse/tests/data/02_tree_struct.py @@ -4,10 +4,12 @@ def nodedict(parent, children=None, previous=None, next_=None) -> dict[str, Any]: if children is None: children = [] - return {'parent_heading': parent, - 'children_heading': children, - 'previous_same_level_heading': previous, - 'next_same_level_heading': next_} + return { + 'parent_heading': parent, + 'children_heading': children, + 'previous_same_level_heading': previous, + 'next_same_level_heading': next_, + } data = [nodedict(*args) for args in [ @@ -43,4 +45,4 @@ def nodedict(parent, children=None, previous=None, next_=None) -> dict[str, Any] ('G6-H1', [], 'G6-H2'), # G7 (None, [], 'G6-H1'), -]] +]] # fmt: skip diff --git a/src/orgparse/tests/data/03_repeated_tasks.py b/src/orgparse/tests/data/03_repeated_tasks.py index fadd5ed..17336e0 100644 --- a/src/orgparse/tests/data/03_repeated_tasks.py +++ b/src/orgparse/tests/data/03_repeated_tasks.py @@ -9,4 +9,4 @@ OrgDateRepeatedTask((2005, 8, 1, 19, 44, 0), 'TODO', 'DONE'), OrgDateRepeatedTask((2005, 7, 1, 17, 27, 0), 'TODO', 'DONE'), ] -}] +}] # fmt: skip diff --git a/src/orgparse/tests/data/04_logbook.py b/src/orgparse/tests/data/04_logbook.py index 085b534..2443683 100644 --- a/src/orgparse/tests/data/04_logbook.py +++ b/src/orgparse/tests/data/04_logbook.py @@ -8,4 +8,4 @@ OrgDateClock((2012, 10, 26, 14, 30), (2012, 10, 26, 14, 40)), OrgDateClock((2012, 10, 26, 14, 10), (2012, 10, 26, 14, 20)), ] -}] +}] # fmt: skip diff --git a/src/orgparse/tests/data/05_tags.py b/src/orgparse/tests/data/05_tags.py index f4038e8..19447f4 100644 --- a/src/orgparse/tests/data/05_tags.py +++ b/src/orgparse/tests/data/05_tags.py @@ -1,4 +1,3 @@ - def nodedict(i, tags): return { "heading": f"Node {i}", @@ -19,4 +18,4 @@ def nodedict(i, tags): {"heading": 'Heading: :with:colon:', "tags": {"tag"}}, ] + [ {"heading": 'unicode', "tags": {'ёж', 'tag', 'háček'}}, - ] + ] # fmt: skip diff --git a/src/orgparse/tests/test_data.py b/src/orgparse/tests/test_data.py index 642ee53..c271273 100644 --- a/src/orgparse/tests/test_data.py +++ b/src/orgparse/tests/test_data.py @@ -1,20 +1,19 @@ -import os import pickle -from glob import glob +from collections.abc import Iterator from pathlib import Path import pytest from .. import load, loads -DATADIR = os.path.join(os.path.dirname(__file__), 'data') +DATADIR = Path(__file__).parent / 'data' -def load_data(path): +def load_data(path: Path): """Load data from python file""" ns = {} # type: ignore[var-annotated] # read_bytes() and compile hackery to avoid encoding issues (e.g. see 05_tags) - exec(compile(Path(path).read_bytes(), path, 'exec'), ns) + exec(compile(path.read_bytes(), path, 'exec'), ns) return ns['data'] @@ -26,10 +25,11 @@ def value_from_data_key(node, key): return node.tags elif key == 'children_heading': return [c.heading for c in node.children] - elif key in ('parent_heading', - 'previous_same_level_heading', - 'next_same_level_heading', - ): + elif key in ( + 'parent_heading', + 'previous_same_level_heading', + 'next_same_level_heading', + ): othernode = getattr(node, key.rsplit('_', 1)[0]) if othernode and not othernode.is_root(): return othernode.heading @@ -39,13 +39,13 @@ def value_from_data_key(node, key): return getattr(node, key) -def data_path(dataname, ext): - return os.path.join(DATADIR, f'{dataname}.{ext}') +def data_path(dataname: str, ext: str) -> Path: + return DATADIR / f'{dataname}.{ext}' -def get_datanames(): - for oname in sorted(glob(os.path.join(DATADIR, '*.org'))): - yield os.path.splitext(os.path.basename(oname))[0] +def get_datanames() -> Iterator[str]: + for oname in sorted(DATADIR.glob('*.org')): + yield oname.stem @pytest.mark.parametrize('dataname', get_datanames()) @@ -57,13 +57,17 @@ def test_data(dataname): data = load_data(data_path(dataname, "py")) root = load(oname) - for (i, (node, kwds)) in enumerate(zip(root[1:], data)): + for i, (node, kwds) in enumerate(zip(root[1:], data)): for key in kwds: val = value_from_data_key(node, key) - assert kwds[key] == val, f'check value of {i}-th node of key "{key}" from "{dataname}".\n\nParsed:\n{val}\n\nReal:\n{kwds[key]}' - assert type(kwds[key]) == type(val), f'check type of {i}-th node of key "{key}" from "{dataname}".\n\nParsed:\n{type(val)}\n\nReal:\n{type(kwds[key])}' # noqa: E721 + assert kwds[key] == val, ( + f'check value of {i}-th node of key "{key}" from "{dataname}".\n\nParsed:\n{val}\n\nReal:\n{kwds[key]}' + ) + assert type(kwds[key]) == type(val), ( # noqa: E721 + f'check type of {i}-th node of key "{key}" from "{dataname}".\n\nParsed:\n{type(val)}\n\nReal:\n{type(kwds[key])}' + ) - assert root.env.filename == oname + assert root.env.filename == str(oname) @pytest.mark.parametrize('dataname', get_datanames()) @@ -73,7 +77,6 @@ def test_picklable(dataname): pickle.dumps(root) - def test_iter_node(): root = loads(""" * H1 diff --git a/src/orgparse/tests/test_date.py b/src/orgparse/tests/test_date.py index 39de638..9764f97 100644 --- a/src/orgparse/tests/test_date.py +++ b/src/orgparse/tests/test_date.py @@ -10,7 +10,6 @@ def test_date_as_string() -> None: - testdate = datetime.date(2021, 9, 3) testdate2 = datetime.date(2021, 9, 5) testdatetime = datetime.datetime(2021, 9, 3, 16, 19, 13) diff --git a/src/orgparse/tests/test_hugedata.py b/src/orgparse/tests/test_hugedata.py index aaa7933..b97a178 100644 --- a/src/orgparse/tests/test_hugedata.py +++ b/src/orgparse/tests/test_hugedata.py @@ -8,16 +8,13 @@ def generate_org_lines(num_top_nodes, depth=3, nodes_per_level=1, _level=1): return for i in range(num_top_nodes): yield ("*" * _level) + f' {i}-th heading of level {_level}' - yield from generate_org_lines( - nodes_per_level, depth - 1, nodes_per_level, _level + 1) + yield from generate_org_lines(nodes_per_level, depth - 1, nodes_per_level, _level + 1) def num_generate_org_lines(num_top_nodes, depth=3, nodes_per_level=1): if depth == 0: return 0 - return num_top_nodes * ( - 1 + num_generate_org_lines( - nodes_per_level, depth - 1, nodes_per_level)) + return num_top_nodes * (1 + num_generate_org_lines(nodes_per_level, depth - 1, nodes_per_level)) def test_picklable() -> None: diff --git a/src/orgparse/tests/test_misc.py b/src/orgparse/tests/test_misc.py index 5c0b3ff..bb1382e 100644 --- a/src/orgparse/tests/test_misc.py +++ b/src/orgparse/tests/test_misc.py @@ -1,3 +1,7 @@ +import io + +import pytest + from orgparse.date import OrgDate from .. import load, loads @@ -17,12 +21,14 @@ def test_empty_heading() -> None: def test_root() -> None: - root = loads(''' + root = loads( + ''' #+STARTUP: hidestars Whatever # comment * heading 1 - '''.strip()) + '''.strip() + ) assert len(root.children) == 1 # todo not sure if should strip special comments?? assert root.body.endswith('Whatever\n# comment') @@ -82,8 +88,7 @@ def test_parse_custom_todo_keys(): env = OrgEnv(todos=todo_keys, dones=done_keys, filename=filename) root = loads(content, env=env) - assert root.env.all_todo_keys == ['TODO', 'CUSTOM1', - 'ANOTHER_KEYWORD', 'DONE', 'A'] + assert root.env.all_todo_keys == ['TODO', 'CUSTOM1', 'ANOTHER_KEYWORD', 'DONE', 'A'] assert len(root.children) == 5 assert root.children[0].todo == 'TODO' assert root.children[1].todo == 'DONE' @@ -107,28 +112,28 @@ def test_add_custom_todo_keys(): # after parsing, all keys are set root = loads(content, filename, env) - assert root.env.all_todo_keys == ['CUSTOM_TODO', 'COMMENT_TODO', - 'CUSTOM_DONE', 'COMMENT_DONE'] + assert root.env.all_todo_keys == ['CUSTOM_TODO', 'COMMENT_TODO', 'CUSTOM_DONE', 'COMMENT_DONE'] + def test_get_file_property() -> None: - content = """#+TITLE: Test: title + content = """#+TITLE: Test: title * Node 1 test 1 * Node 2 test 2 """ - # after parsing, all keys are set - root = loads(content) - assert root.get_file_property('Nosuchproperty') is None - assert root.get_file_property_list('TITLE') == ['Test: title'] - # also it's case insensitive - assert root.get_file_property('title') == 'Test: title' - assert root.get_file_property_list('Nosuchproperty') == [] + # after parsing, all keys are set + root = loads(content) + assert root.get_file_property('Nosuchproperty') is None + assert root.get_file_property_list('TITLE') == ['Test: title'] + # also it's case insensitive + assert root.get_file_property('title') == 'Test: title' + assert root.get_file_property_list('Nosuchproperty') == [] def test_get_file_property_multivalued() -> None: - content = """ #+TITLE: Test + content = """ #+TITLE: Test #+OTHER: Test title #+title: alternate title @@ -138,14 +143,13 @@ def test_get_file_property_multivalued() -> None: test 2 """ - # after parsing, all keys are set - root = loads(content) - import pytest + # after parsing, all keys are set + root = loads(content) - assert root.get_file_property_list('TITLE') == ['Test', 'alternate title'] - with pytest.raises(RuntimeError): - # raises because there are multiple of them - root.get_file_property('TITLE') + assert root.get_file_property_list('TITLE') == ['Test', 'alternate title'] + with pytest.raises(RuntimeError): + # raises because there are multiple of them + root.get_file_property('TITLE') def test_filetags_are_tags() -> None: @@ -163,7 +167,6 @@ def test_filetags_are_tags() -> None: def test_load_filelike() -> None: - import io stream = io.StringIO(''' * heading1 * heading 2 @@ -216,6 +219,7 @@ def test_level_0_timestamps() -> None: OrgDate((2019, 8, 10, 16, 30, 0), (2019, 8, 10, 17, 30, 0)), ] + def test_date_with_cookies() -> None: testcases = [ ('<2010-06-21 Mon +1y>', @@ -236,8 +240,8 @@ def test_date_with_cookies() -> None: "OrgDate((2019, 4, 5, 8, 0, 0), None, False, ('.+', 1, 'h'))"), ('<2007-05-16 Wed 12:30 +1w>', "OrgDate((2007, 5, 16, 12, 30, 0), None, True, ('+', 1, 'w'))"), - ] - for (inp, expected) in testcases: + ] # fmt: skip + for inp, expected in testcases: root = loads(inp) output = root[0].datelist[0] assert str(output) == inp @@ -247,8 +251,8 @@ def test_date_with_cookies() -> None: "OrgDate((2006, 11, 2, 20, 0, 0), (2006, 11, 2, 22, 0, 0), True, ('+', 1, 'w'))"), ('<2006-11-02 Thu 20:00--22:00 +1w>', "OrgDate((2006, 11, 2, 20, 0, 0), (2006, 11, 2, 22, 0, 0), True, ('+', 1, 'w'))"), - ] - for (inp, expected) in testcases: + ] # fmt: skip + for inp, expected in testcases: root = loads(inp) output = root[0].rangelist[0] assert str(output) == "<2006-11-02 Thu 20:00--22:00 +1w>" @@ -270,8 +274,8 @@ def test_date_with_cookies() -> None: ('* TODO Pay the rent\nDEADLINE: <2005-10-01 Sat .+1m>', "<2005-10-01 Sat .+1m>", "OrgDateDeadline((2005, 10, 1), None, True, ('.+', 1, 'm'))"), - ] - for (inp, expected_str, expected_repr) in testcases2: + ] # fmt: skip + for inp, expected_str, expected_repr in testcases2: root = loads(inp) output = root[1].deadline assert str(output) == expected_str @@ -292,8 +296,8 @@ def test_date_with_cookies() -> None: ('* TODO Pay the rent\nSCHEDULED: <2005-10-01 Sat .+1m>', "<2005-10-01 Sat .+1m>", "OrgDateScheduled((2005, 10, 1), None, True, ('.+', 1, 'm'))"), - ] - for (inp, expected_str, expected_repr) in testcases2: + ] # fmt: skip + for inp, expected_str, expected_repr in testcases2: root = loads(inp) output = root[1].scheduled assert str(output) == expected_str diff --git a/src/orgparse/tests/test_rich.py b/src/orgparse/tests/test_rich.py index e423b0d..5171bb0 100644 --- a/src/orgparse/tests/test_rich.py +++ b/src/orgparse/tests/test_rich.py @@ -1,6 +1,7 @@ ''' Tests for rich formatting: tables etc. ''' + import pytest from .. import loads @@ -36,7 +37,7 @@ def test_table() -> None: | value2 | ''') - [gap1, t1, gap2, t2, gap3, t3, gap4] = root.body_rich + [_gap1, t1, _gap2, t2, _gap3, t3, _gap4] = root.body_rich t1 = Table(root._lines[1:10]) t2 = Table(root._lines[11:19]) @@ -47,13 +48,12 @@ def test_table() -> None: assert ilen(t1.rows) == 6 with pytest.raises(RuntimeError): - list(t1.as_dicts) # not sure what should it be + list(t1.as_dicts) # not sure what should it be assert ilen(t2.blocks) == 2 assert ilen(t2.rows) == 5 assert list(t2.rows)[3] == ['[2020-11-05 Thu 23:44]', ''] - assert ilen(t3.blocks) == 2 assert list(t3.rows) == [['simple'], ['value1'], ['value2']] assert t3.as_dicts.columns == ['simple'] diff --git a/tox.ini b/tox.ini index c99ef94..a31cbab 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] minversion = 3.21 # relies on the correct version of Python installed -envlist = ruff,tests,mypy +envlist = ruff,tests,mypy,ty # https://github.com/tox-dev/tox/issues/20#issuecomment-247788333 # hack to prevent .tox from crapping to the project directory toxworkdir = {env:TOXWORKDIR_BASE:}{toxinidir}/.tox @@ -9,7 +9,7 @@ toxworkdir = {env:TOXWORKDIR_BASE:}{toxinidir}/.tox [testenv] # TODO how to get package name from setuptools? package_name = "orgparse" -passenv = +pass_env = # useful for tests to know they are running under ci CI CI_* @@ -18,6 +18,11 @@ passenv = MYPY_CACHE_DIR RUFF_CACHE_DIR +set_env = +# do not add current working directory to pythonpath +# generally this is more robust and safer, prevents weird issues later on + PYTHONSAFEPATH=1 + # default is 'editable', in which tox builds wheel first for some reason? not sure if makes much sense package = uv-editable @@ -26,7 +31,7 @@ package = uv-editable skip_install = true dependency_groups = testing commands = - {envpython} -m ruff check src/ \ + {envpython} -m ruff check \ {posargs} @@ -44,7 +49,19 @@ dependency_groups = testing commands = {envpython} -m mypy --no-install-types \ -p {[testenv]package_name} \ - # txt report is a bit more convenient to view on CI - --txt-report .coverage.mypy \ - --html-report .coverage.mypy \ + --txt-report .coverage.mypy \ + --html-report .coverage.mypy \ + # this is for github actions to upload to codecov.io + # sadly xml coverage crashes on windows... so we need to disable it + {env:CI_MYPY_COVERAGE} \ + {posargs} + + +[testenv:ty] +dependency_groups = testing +extras = optional +deps = # any other dependencies (if needed) +commands = + {envpython} -m ty \ + check \ {posargs} From d4613425a1b5cde3122f99e2be2ef604f731744b Mon Sep 17 00:00:00 2001 From: Dima Gerasimov Date: Thu, 30 Oct 2025 22:15:04 +0000 Subject: [PATCH 2/2] readme: add project status section see https://github.com/karlicoss/orgparse/issues/74 --- README.rst | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/README.rst b/README.rst index 130e7ac..4020edf 100644 --- a/README.rst +++ b/README.rst @@ -96,3 +96,13 @@ True 'some text' >>> node.body ' Body texts...' + + +Project status +-------------- + +Project is maintained by @karlicoss (myself). + +For my personal use, orgparse mostly has all features I need, so there hasn't been much active development lately. + +However, contributions are always welcome! Please provide tests along with your contribution if you're fixing bugs or adding new functionality.