diff options
| author | Steven Van Dorp <steven@vandorp.lu> | 2026-02-01 15:13:01 +0100 |
|---|---|---|
| committer | Steven Van Dorp <steven@vandorp.lu> | 2026-02-01 15:13:01 +0100 |
| commit | c7987858924608f8013e64260efb806e5ddeab9f (patch) | |
| tree | 06d1a0e487cabf896339386d2c1ecf02f87a540e | |
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | .pylintrc | 664 | ||||
| -rw-r--r-- | issue-tracker/20260201_140007/issue.md | 10 | ||||
| -rw-r--r-- | issue-tracker/20260201_140421/issue.md | 17 | ||||
| -rw-r--r-- | issue-tracker/_config.ini | 3 | ||||
| -rw-r--r-- | mypy.ini | 10 | ||||
| -rwxr-xr-x | src/xpit | 8 | ||||
| -rw-r--r-- | src/xpit_/args_helpers.py | 102 | ||||
| -rw-r--r-- | src/xpit_/create.py | 263 | ||||
| -rw-r--r-- | src/xpit_/error.py | 4 | ||||
| -rw-r--r-- | src/xpit_/identity.py | 59 | ||||
| -rw-r--r-- | src/xpit_/init.py | 54 | ||||
| -rw-r--r-- | src/xpit_/issue.py | 168 | ||||
| -rw-r--r-- | src/xpit_/issue_tracker.py | 52 | ||||
| -rw-r--r-- | src/xpit_/list_.py | 104 | ||||
| -rw-r--r-- | src/xpit_/xpit.py | 53 |
16 files changed, 1574 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d703a65 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +./.mypy_cache/ +**/__pycache__/ + diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..dd1b45b --- /dev/null +++ b/.pylintrc @@ -0,0 +1,664 @@ +[MAIN] + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Clear in-memory caches upon conclusion of linting. Useful if running pylint +# in a server-like mode. +clear-cache-post-run=no + +# Load and enable all available extensions. Use --list-extensions to see a list +# all available extensions. +#enable-all-extensions= + +# In error mode, messages with a category besides ERROR or FATAL are +# suppressed, and no reports are done by default. Error mode is compatible with +# disabling specific errors. +#errors-only= + +# Always return a 0 (non-error) status code, even if lint errors are found. +# This is primarily useful in continuous integration scripts. +#exit-zero= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-allow-list= + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. (This is an alternative name to extension-pkg-allow-list +# for backward compatibility.) +extension-pkg-whitelist= + +# Return non-zero exit code if any of these messages/categories are detected, +# even if score is above --fail-under value. Syntax same as enable. Messages +# specified are enabled, while categories only check already-enabled messages. +fail-on= + +# Specify a score threshold under which the program will exit with error. +fail-under=10 + +# Interpret the stdin as a python script, whose filename needs to be passed as +# the module_or_package argument. +#from-stdin= + +# Files or directories to be skipped. They should be base names, not paths. +ignore=CVS + +# Add files or directories matching the regular expressions patterns to the +# ignore-list. The regex matches against paths and can be in Posix or Windows +# format. Because '\\' represents the directory delimiter on Windows systems, +# it can't be used as an escape character. +ignore-paths= + +# Files or directories matching the regular expression patterns are skipped. +# The regex matches against base names, not paths. The default value ignores +# Emacs file locks +ignore-patterns=^\.# + +# List of module names for which member attributes should not be checked and +# will not be imported (useful for modules/projects where namespaces are +# manipulated during runtime and thus existing member attributes cannot be +# deduced by static analysis). It supports qualified module names, as well as +# Unix pattern matching. +ignored-modules= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use, and will cap the count on Windows to +# avoid hangs. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# Resolve imports to .pyi stubs if available. May reduce no-member messages and +# increase not-an-iterable messages. +prefer-stubs=no + +# Minimum Python version to use for version dependent checks. Will default to +# the version used to run pylint. +py-version=3.13 + +# Discover python modules and packages in the file system subtree. +recursive=no + +# Add paths to the list of the source roots. Supports globbing patterns. The +# source root is an absolute path or a path relative to the current working +# directory used to determine a package namespace for modules located under the +# source root. +source-roots= + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# In verbose mode, extra non-checker-related info will be displayed. +#verbose= + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. If left empty, argument names will be checked with the set +# naming style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. If left empty, attribute names will be checked with the set naming +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. If left empty, class attribute names will be checked +# with the set naming style. +#class-attribute-rgx= + +# Naming style matching correct class constant names. +class-const-naming-style=UPPER_CASE + +# Regular expression matching correct class constant names. Overrides class- +# const-naming-style. If left empty, class constant names will be checked with +# the set naming style. +#class-const-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. If left empty, class names will be checked with the set naming style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. If left empty, constant names will be checked with the set naming +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. If left empty, function names will be checked with the set +# naming style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. If left empty, inline iteration names will be checked +# with the set naming style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. If left empty, method names will be checked with the set naming style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. If left empty, module names will be checked with the set naming style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# Regular expression matching correct parameter specification variable names. +# If left empty, parameter specification variable names will be checked with +# the set naming style. +#paramspec-rgx= + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Regular expression matching correct type alias names. If left empty, type +# alias names will be checked with the set naming style. +#typealias-rgx= + +# Regular expression matching correct type variable names. If left empty, type +# variable names will be checked with the set naming style. +#typevar-rgx= + +# Regular expression matching correct type variable tuple names. If left empty, +# type variable tuple names will be checked with the set naming style. +#typevartuple-rgx= + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. If left empty, variable names will be checked with the set +# naming style. +#variable-rgx= + + +[CLASSES] + +# Warn about protected attribute access inside special methods +check-protected-access-in-special-methods=no + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + asyncSetUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[DESIGN] + +# List of regular expressions of class ancestor names to ignore when counting +# public methods (see R0903) +exclude-too-few-public-methods= + +# List of qualified class names to ignore when counting class parents (see +# R0901) +ignored-parents= + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of positional arguments for function / method. +max-positional-arguments=5 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when caught. +overgeneral-exceptions=builtins.BaseException,builtins.Exception + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )?<?https?://\S+>?$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. Pylint's default of 100 is +# based on PEP 8's guidance that teams may choose line lengths up to 99 +# characters. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow explicit reexports by alias from a package __init__. +allow-reexport-from-package=no + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules= + +# Output a graph (.gv or any supported image format) of external dependencies +# to the given file (report RP0402 must not be disabled). +ext-import-graph= + +# Output a graph (.gv or any supported image format) of all (i.e. internal and +# external) dependencies to the given file (report RP0402 must not be +# disabled). +import-graph= + +# Output a graph (.gv or any supported image format) of internal dependencies +# to the given file (report RP0402 must not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE, +# UNDEFINED. +confidence=HIGH, + CONTROL_FLOW, + INFERENCE, + INFERENCE_FAILURE, + UNDEFINED + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then re-enable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=no-name-in-module, + trailing-newlines, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + no-else-break, + no-else-return, + + too-many-branches, + too-many-return-statements, + too-few-public-methods, + too-many-statements, + too-many-locals, + too-many-instance-attributes, + + invalid-name, + fixme + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable= + + +[METHOD_ARGS] + +# List of qualified names (i.e., library.method) which require a timeout +# parameter e.g. 'requests.api.get,requests.api.post' +timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request + + +[MISCELLANEOUS] + +# Whether or not to search for fixme's in docstrings. +check-fixme-in-docstring=no + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +notes-rgx= + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit,argparse.parse_error + +# Let 'consider-using-join' be raised when the separator to join on would be +# non-empty (resulting in expected fixes of the type: ``"- " + " - +# ".join(items)``) +suggest-join-with-non-empty-separator=yes + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'fatal', 'error', 'warning', 'refactor', +# 'convention', and 'info' which contain the number of messages in each +# category, as well as 'statement' which is the total number of statements +# analyzed. This score is used by the global evaluation report (RP0004). +evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10)) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +msg-template= + +# Set the output format. Available formats are: 'text', 'parseable', +# 'colorized', 'json2' (improved json format), 'json' (old json format), msvs +# (visual studio) and 'github' (GitHub actions). You can also give a reporter +# class, e.g. mypackage.mymodule.MyReporterClass. +#output-format= + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[SIMILARITIES] + +# Comments are removed from the similarity computation +ignore-comments=yes + +# Docstrings are removed from the similarity computation +ignore-docstrings=yes + +# Imports are removed from the similarity computation +ignore-imports=yes + +# Signatures are removed from the similarity computation +ignore-signatures=yes + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. No available dictionaries : You need to install +# both the python package and the system dependency for enchant to work. +spelling-dict= + +# List of comma separated words that should be considered directives if they +# appear at the beginning of a comment and should not be checked. +spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy: + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of symbolic message names to ignore for Mixin members. +ignored-checks-for-mixins=no-member, + not-async-context-manager, + not-context-manager, + attribute-defined-outside-init + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The maximum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# Regex pattern to define which classes are considered mixins. +mixin-class-rgx=.*[Mm]ixin + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of names allowed to shadow builtins +allowed-redefined-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io diff --git a/issue-tracker/20260201_140007/issue.md b/issue-tracker/20260201_140007/issue.md new file mode 100644 index 0000000..b7f9712 --- /dev/null +++ b/issue-tracker/20260201_140007/issue.md @@ -0,0 +1,10 @@ +```ini +author=steven#Bt0vSTFm +status=open +labels=proposal +``` + +# Validation + +Something like `xpit validate` or `xpit fsck`. Could be useful for CI/CD. + diff --git a/issue-tracker/20260201_140421/issue.md b/issue-tracker/20260201_140421/issue.md new file mode 100644 index 0000000..69cd331 --- /dev/null +++ b/issue-tracker/20260201_140421/issue.md @@ -0,0 +1,17 @@ +```ini +author=steven#Bt0vSTFm +status=open +labels=proposal +``` + +# Robustly modify metadata from command line + +Right now, we're mostly manually editing files, +but it might be useful to do something like `xpit set XXX status=close`. +Right now, the obvious way to do that would be something like +`sed -i 's/status=closed/status=open/'`, which could cause unintended +side-effects in other parts of the file. + +It might be more elegant to create a separate general utility to modify "code" +inside markdown files, maybe. + diff --git a/issue-tracker/_config.ini b/issue-tracker/_config.ini new file mode 100644 index 0000000..d2b0158 --- /dev/null +++ b/issue-tracker/_config.ini @@ -0,0 +1,3 @@ +id_format=%Y%m%d_%H%M%S ; `man strftime` for format +allowed_labels=bug,breaking,contributor-friendly,docs,use-case,regression,proposal ; labels that are allowed to be used in issues. (Comma-separated list) + diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..0986d50 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,10 @@ +[mypy] +python_version=3.10 +warn_return_any=true +warn_unused_ignores=true +strict_optional=true +warn_redundant_casts = true +warn_no_return = true +check_untyped_defs=true +disallow_any_generics=true +disallow_subclassing_any=true diff --git a/src/xpit b/src/xpit new file mode 100755 index 0000000..2b954ab --- /dev/null +++ b/src/xpit @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 + +import sys +from xpit_.xpit import main + +if __name__ == "__main__": + sys.exit(main()) + diff --git a/src/xpit_/args_helpers.py b/src/xpit_/args_helpers.py new file mode 100644 index 0000000..1e79911 --- /dev/null +++ b/src/xpit_/args_helpers.py @@ -0,0 +1,102 @@ +from sys import stderr +from . import error +Err = error.Err + +def print_error(msg: str) -> None: + stderr.write(f"{msg}\n") + +def err_arg_unrecognized(arg: str) -> None: + print_error(f"Unrecognized argument: {arg}") + +ERR_UNRECOGNIZED_ARG = "ERR_UNRECOGNIZED_ARG" +ERR_NOT_ENOUGH_ARGS = "ERR_NOT_ENOUGH_ARGS" + +def parse_arg( + argv: list[str], + i: int, + flag_value_map: dict[str, int] +) -> tuple[str, list[str], int, Err | None]: + def success( + flag: str, + values: list[str], + new_i: int + ) -> tuple[str, list[str], int, Err | None]: + return flag, values, new_i, None + def fail(msg: str) -> tuple[str, list[str], int, Err | None]: + return "", [], 0, Err(msg) + + flag: str + values: list[str] = [] + arg: str = argv[i] + i += 1 + if arg[0] != '-': + err_arg_unrecognized(arg) + return fail(ERR_UNRECOGNIZED_ARG) + + value_index = 0 + if arg[1] == '-': + # --flag + delimiter_index = arg.find('=') + if delimiter_index == -1: + flag = arg[2:] + value_index = 0 + else: + flag = arg[2:delimiter_index] + values.append(arg[delimiter_index+1:]) + value_index = 1 + + else: + # -f + flag = arg[1] + value_index = 0 + + if len(arg) != 2: + value = arg[2:] + if value[0] == '=': + value = value[1:] + values.append(value) + value_index += 1 + target_flag_set_arr = None + value_count: int = 0 + for flag_set in flag_value_map: + flag_set_arr = flag_set.split('|') + if flag in flag_set_arr: + target_flag_set_arr = flag_set_arr + value_count = flag_value_map[flag_set] + break + if target_flag_set_arr is None: + err_arg_unrecognized(flag) + return fail(ERR_UNRECOGNIZED_ARG) + while value_index < value_count: + if i >= len(argv): + print_error(f"Not enough arguments for flag '{flag}'") + return fail(ERR_NOT_ENOUGH_ARGS) + values.append(argv[i]) + i += 1 + value_index += 1 + + # NOTE: we always return the first flag in the flag-set for normalization + return success(target_flag_set_arr[0], values, i) + +def parse_generic( + flag_value_map: dict[str, int], + argv: list[str], + i: int +) -> dict[str, list[str]] | None: + res: dict[str, list[str]] = {} + while i < len(argv): + arg = argv[i] + if len(arg) >= 2: + flag, values, i, err = parse_arg(argv, i, flag_value_map) + if err is not None: + return None + + res[flag] = values + #if not callback(flag, values): + # return False + else: + err_arg_unrecognized(arg) + return None + + return res + diff --git a/src/xpit_/create.py b/src/xpit_/create.py new file mode 100644 index 0000000..30d32fa --- /dev/null +++ b/src/xpit_/create.py @@ -0,0 +1,263 @@ +import os +import subprocess +import getpass +from sys import argv, stdout, stderr +from datetime import datetime, timezone +from . import args_helpers, identity, issue, issue_tracker + +help_text = """\ +Usage: xpit create [options] +Examples: + xpit create + xpit create -u Bob -i secret_bob_alias -r 20260101_120000:20260101_140000 -x + xpit create -e_ -t "Startup slow" -l performance,startup -d "The window takes 200ms to open." +Options: + -d, --description <str> + The post body. + Defaults to 'Description goes here...' + -x, --disable-inline-annotations + Disable the explanatory ; annotations in newly created posts' headers. + -e, --editor <str> + Which editor to open the issue in. + Pass '_' (underscore) to not open an editor. + If not specified, the editor is chosen in the following order: + 1. $XPIT_EDITOR + 2. $GIT_EDITOR + 3. $EDITOR + 4. $VISUAL + 5. + windows: "notepad" + other: "vi" + -i, --identity <str> + Label for keypair used for unique identification and signature. + This is used to uniquely identify a user. + Keys are stored in '~/.config/xpit/identities/'. + Defaults to 'default'. + -l, --labels <str> + Comma-separated list of labels applicable to this issue. + Not compatible with --reply-to. + Empty by default. + -r, --reply-to <ISSUE_ID>[:LOCAL_ID] + Reply to an issue or a specific reply within an issue. + -P, --skip-prompt + Skip interactive prompts for missing fields (title, description, labels). + Only applies when using `-e_` (no automatic editor). + -t, --title <str> + The issue's title. + Not compatible with --reply-to. + Defaults to 'Title'. + -u, --username <str> + The username to use for the issue. + If not specified, uses `getpass.getuser()`. + -h, --help + Print this help text. +""" + +class Args: + def __init__(self): + self.config_dir: str + self.editor: str + self.username: str + self.identity: str = "default" + self.reply_to: str | None = None + self.disable_inline_annotations: bool = False + + self.labels: bytes = b"" + self.skip_prompt: bool = False + self.description: bytes = b"Description goes here..." + self.title: bytes = b"Title" + + def parse(self) -> bool: + flag_value_map = { + "help|h": 0, + "description|d": 1, + "disable-inline-annotations|x": 0, + "editor|e": 1, + "identity|i": 1, + "labels|l": 1, + "reply|r": 1, + "skip-prompt|P": 0, + "title|t|": 1, + "username|u": 1, + } + res = args_helpers.parse_generic(flag_value_map, argv, 2) + _editor: str | None = None + _username: str | None = getpass.getuser() + if res is None: + return False + if "help" in res: + stdout.write(help_text) + return False + if "description" in res: + self.description = res["description"][0].encode("utf-8") + if "disable-inline-annotations" in res: + self.disable_inline_annotations = True + if "editor" in res: + _editor = res["editor"][0] + if "identity" in res: + self.identity = res["identity"][0] + if "labels" in res: + labels: str = res["labels"][0] + issue_tracker.load_config() + for label in labels.split(','): + if label.encode("utf-8") not in issue_tracker.config[b"allowed_labels"]: + stderr.write( + f"Label '{label}' not allowed.\nAllowed: " + +issue_tracker.config[b"allowed_labels"].decode("utf-8") + +'\n') + return False + self.labels = labels.encode("utf-8") + if "reply" in res: + self.reply_to = res["reply"][0] + if "skip-prompt" in res: + self.skip_prompt = True + if "title" in res: + self.title = res["title"][0].encode("utf-8") + if "username" in res: + _username = res["username"][0] + + if _username is None: + stderr.write("No username.") + return False + self.username = _username + + if _editor is None: + _editor = ( + os.environ.get("XPIT_EDITOR") + or os.environ.get("GIT_EDITOR") + or os.environ.get("EDITOR") + or os.environ.get("VISUAL") + or None + ) + if _editor is None: + if os.name == "nt": + _editor = "notepad" + else: + _editor = "vi" + self.editor = _editor + + if os.name == "nt": + appdata = os.getenv("APPDATA") + if appdata is None: + stderr.write("Couldn't find $APPDATA directory") + return False + self.config_dir = os.path.join(appdata, "xpit") + else: + home = os.getenv("HOME") + if home is None: + stderr.write("Couldn't find $HOME directory") + return False + self.config_dir = os.path.join(home, ".config", "xpit") + + return True +args = Args() + +def id_gen() -> str: + return datetime.now(timezone.utc).strftime(issue_tracker.config[b"id_format"].decode("utf-8")) + +def main() -> int: + if len(argv) < 2: + stdout.write(help_text) + return 1 + + if not args.parse(): + return 1 + + identities_dir = os.path.join(args.config_dir, "identities") + private_key, public_key = identity.load(identities_dir, args.identity) + _ = private_key + + issue_tracker.load_config() + if issue_tracker.dir is None: + stderr.write("xpit not initialized. Run `xpit init`.\n") + return 1 + + if args.reply_to is None: + issue_id = id_gen() + else: + issue_id = args.reply_to.split(":", 1)[0] + issue_dir = os.path.join(issue_tracker.dir, issue_id) + if args.reply_to is None: + if os.path.exists(issue_dir): + stderr.write(f"Failed to create issue {issue_id}: Already exists.\n") + return 1 + os.mkdir(issue_dir) + + issue_tracker_path = os.path.join(issue_dir, "issue.md") + if args.reply_to is not None: + if not os.path.isfile(issue_tracker_path): + stderr.write(f"Issue {issue_id} not found.\n") + return 1 + with open(issue_tracker_path, "r+b") as f: + src = f.read() + info, err = issue.parse(src) + if err is not None: + stderr.write(err.msg) + return 1 + if info.meta[b"status"] != b"open": + stderr.write(f"Issue {issue_id} already closed.\n") + return 1 + + identity_id = identity.id_from_pubkey(public_key) + # help_author = b" ; your user-ID" + # help_status = b" ; open | closed" + allowed_labels = issue_tracker.config[b"allowed_labels"] + help_labels = b" ; comma-separated list. Allowed labels are: "+allowed_labels + # help_id = b" ; this reply's local ID" + # help_reply = b" ; local ID of the post you're replying to" + if args.disable_inline_annotations: + help_labels = b"" + if args.editor == "_" and not args.skip_prompt: + if args.title is None: + args.title = input("Title: ").encode("utf-8") + if args.labels is None: + args.labels = input("Labels (Comma-separated): ").encode("utf-8") + if args.description is None: + args.description = input("Description: ").encode("utf-8") + with open(issue_tracker_path, "ab") as f: + ini_author = b"author=" + args.username.encode("utf-8") + b'#' + identity_id + b'\n' + if args.reply_to is None: + ini_status = b"status=open\n" + ini_labels = b"labels="+args.labels + help_labels + b'\n' + ini_add_after_edit = ini_author + ini_status + ini_add_now = ini_labels + if args.editor == "_": + ini_add_now = ini_add_after_edit + ini_add_now + msg = b"```ini\n"+ini_add_now+b"""``` + +# """+args.title+b""" + +"""+args.description+b""" + +""" + else: + ini_id = b"id="+id_gen().encode("utf-8")+b'\n' + reply_to = args.reply_to.split(":", 2) + ini_reply = b"" + if len(reply_to) == 2: + ini_reply = b"reply="+reply_to[1].encode("utf-8")+b'\n' + ini_add_after_edit = ini_author + ini_id + ini_add_now = ini_reply + if args.editor == "_": + ini_add_now = ini_add_after_edit + ini_add_now + msg = b"---\n```ini\n"+ini_add_now+b"```\n\n"+args.description+b"\n\n" + f.write(msg) + + if args.editor != "_": + subprocess.call([args.editor, issue_tracker_path]) + with open(issue_tracker_path, "r+b") as f: + src = f.read() + src = issue.clean_after_edit(src, ini_add_after_edit) + f.seek(0) + f.write(src) + f.truncate() + # TODO check if labels ok. Maybe we want to have some sort of `check` command + + relpath = os.path.relpath(issue_tracker_path, os.getcwd()) + if args.reply_to is None: + stdout.write(f"Created new issue ./{relpath}\n") + else: + stdout.write(f"Replied to {args.reply_to}\n") + + return 0 + diff --git a/src/xpit_/error.py b/src/xpit_/error.py new file mode 100644 index 0000000..12cf084 --- /dev/null +++ b/src/xpit_/error.py @@ -0,0 +1,4 @@ +class Err: + def __init__(self, msg: str): + self.msg = msg + diff --git a/src/xpit_/identity.py b/src/xpit_/identity.py new file mode 100644 index 0000000..2786b2d --- /dev/null +++ b/src/xpit_/identity.py @@ -0,0 +1,59 @@ +import os +import base64 +from sys import stdout +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ed25519 +from cryptography.hazmat.primitives.asymmetric.types import PrivateKeyTypes, PublicKeyTypes + +# TODO guard against bad file access +def create(path: str) -> None: + private_key = ed25519.Ed25519PrivateKey.generate() + public_key = private_key.public_key() + enc = serialization.NoEncryption() + + os.mkdir(path) + + data = private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=enc, + ) + with open(os.path.join(path, "private.pem"), "wb") as f: + f.write(data) + + data = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + with open(os.path.join(path, "public.pub"), "wb") as f: + f.write(data) + +def load(identities_dir: str, identity: str) -> tuple[PrivateKeyTypes, PublicKeyTypes]: + if not os.path.isdir(identities_dir): + os.makedirs(identities_dir) + + path = os.path.join(identities_dir, identity) + + if not os.path.isdir(path): + stdout.write(f"Generating new identity '{identity}'...\n") + create(path) + stdout.write(f"Created new identity '{identity}' at: {path}\n") + + with open(os.path.join(path, "private.pem"), "rb") as f: + private_key_data = f.read() + with open(os.path.join(path, "public.pub"), "rb") as f: + public_key_data = f.read() + + return ( + serialization.load_pem_private_key(private_key_data, password=None), + serialization.load_pem_public_key(public_key_data) + ) + +def id_from_pubkey(pubkey: PublicKeyTypes) -> bytes: + der = pubkey.public_bytes( + encoding=serialization.Encoding.DER, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + b64 = base64.b64encode(der) + return b64[16:24] + diff --git a/src/xpit_/init.py b/src/xpit_/init.py new file mode 100644 index 0000000..3663f4f --- /dev/null +++ b/src/xpit_/init.py @@ -0,0 +1,54 @@ +import os +from sys import argv, stdout, stderr +from . import args_helpers, issue_tracker + +help_text = """\ +Usage: xpit init +Options: + -h, --help + Print this help text. +""" + +class Args: + def __init__(self): + pass + + def parse(self) -> bool: + flag_value_map = { + "help|h": 0, + } + res = args_helpers.parse_generic(flag_value_map, argv, 2) + if res is None: + return False + if "help" in res: + stdout.write(help_text) + return False + + return True +args = Args() + +def main() -> int: + if len(argv) < 2: + stdout.write(help_text) + return 1 + + if not args.parse(): + return 1 + + if issue_tracker.find_dir(os.getcwd(), suppress_warning=True) is not None: + stderr.write("xpit already initialized\n") + return 1 + os.mkdir("issue-tracker") + config_file_path = os.path.join("issue-tracker", "_config.ini") + with open(config_file_path, "wb") as f: + f.write(b"""\ +id_format=%Y%m%d_%H%M%S ; `man strftime` for format +allowed_labels=bug,breaking,contributor-friendly,docs,use-case,regression,proposal ; labels that are allowed to be used in issues. (Comma-separated list) +\n""") + stdout.write(f"""\ +Successfully initialized at ./issue-tracker. +Configure in {config_file_path} +""") + + return 0 + diff --git a/src/xpit_/issue.py b/src/xpit_/issue.py new file mode 100644 index 0000000..53efeef --- /dev/null +++ b/src/xpit_/issue.py @@ -0,0 +1,168 @@ +from __future__ import annotations +from . import error +Err = error.Err + +class Post: + def __init__(self): + self.meta: dict[bytes, bytes] = {} + self.title: bytes = b"" + self.replies: list[Post] = [] + + def reply_get(self, needle: bytes) -> Post | None: + for reply in self.replies: + if b"id" not in reply.meta: + continue + if reply.meta[b"id"] == needle: + return reply + res = reply.reply_get(needle) + if res is not None: + return res + return None + + def post_count(self) -> int: + count = 1 + for reply in self.replies: + count += reply.post_count() + return count + +def is_newline(c: int) -> bool: + return ( + c == ord('\n') + or c == ord('\r') + ) + +def is_whitespace(c: int) -> bool: + return ( + c == ord(' ') + or c == ord('\t') + or is_newline(c) + ) + +def parse_ini(src: bytes | memoryview, i: int, res: dict[bytes, bytes]) -> int: + src = memoryview(src) + + STATE_START = 0 + STATE_KEY = 1 + STATE_VALUE = 2 + state = STATE_START + + key = bytearray() + value = bytearray() + + while i < len(src): + if state == STATE_START: + if src[i:i+4] == b"```\n": + return i + 4 + elif src[i] == ord(' '): + pass + else: + state = STATE_KEY + continue + elif state == STATE_KEY: + if src[i] == ord('='): + state = STATE_VALUE + else: + key.append(src[i]) + elif state == STATE_VALUE: + if is_newline(src[i]) or src[i] == ord(';'): + if src[i] == ord(';'): + while len(value) > 0 and is_whitespace(value[-1]): + value.pop() + while i < len(src) and not is_newline(src[i]): + i += 1 + res[bytes(key)] = bytes(value) + state = STATE_START + key.clear() + value.clear() + else: + value.append(src[i]) + else: + assert False, "unreachable: state enum not exhausted" + + i += 1 + + return -1 + +def parse(src: bytes | memoryview) -> tuple[Post, Err | None]: + def success(post: Post) -> tuple[Post, Err | None]: + return post, None + def fail(msg: str) -> tuple[Post, Err | None]: + return Post(), Err(msg) + + src = memoryview(src) + issue = Post() + issue_title: bytes | None = None + i = 0 + + post = issue + while i < len(src): + if src[i:i+4] == b"\n---": + i += 4 + post = Post() + continue + if src[i:i+7] == b"```ini\n": + i += 7 + i = parse_ini(src, i, post.meta) + if i == -1: + return fail("ERR_FAILED_TO_PARSE_INI") + if b"reply" in post.meta: + reply = issue.reply_get(post.meta[b"reply"]) + if reply is None: + return fail("ERR_REPLY_TO_NONEXISTENT_ID") + reply.replies.append(post) + elif post != issue: + issue.replies.append(post) + continue + if issue_title is None and src[i:i+2] == b"# ": + i += 2 + title_start = i + while i < len(src): + if is_newline(src[i]): + break + i += 1 + issue_title = bytes(src[title_start:i]) + continue + + i += 1 + + if issue_title is None: + return fail("ERR_ISSUE_NO_TITLE") + issue.title = issue_title + + return success(issue) + +def clean_after_edit(src: bytes | memoryview, last_ini_prefix: bytes) -> bytes: + src = memoryview(src) + last_ini_prefix = bytes(last_ini_prefix) + res: list[int] = [] + i = 0 + last_ini_start = 0 + while i < len(src): + while i < len(src): + if src[i:i+7] == b"```ini\n": + res.extend(src[i:i+7]) + last_ini_start = len(res) + i += 7 + break + res.append(src[i]) + i += 1 + + while i < len(src): + # inside ini + if src[i:i+4] == b"\n```": + res.extend(src[i:i+4]) + i += 4 + break + elif src[i:i+3] == b" ; ": + i += 3 + while i < len(src): + if is_newline(src[i]): + break + i += 1 + res.append(src[i]) + i += 1 + + res[last_ini_start:last_ini_start] = last_ini_prefix + + return bytes(res) + diff --git a/src/xpit_/issue_tracker.py b/src/xpit_/issue_tracker.py new file mode 100644 index 0000000..20c1662 --- /dev/null +++ b/src/xpit_/issue_tracker.py @@ -0,0 +1,52 @@ +import os +import sys +from . import issue +from . import error +Err = error.Err + +class State: + def __init__(self): + self.dir: str | None = None + self.config: dict[bytes, bytes] | None = None + self.allowed_labels: list[bytes] | None = None +state = State() + +def find_dir(start_dir: str, suppress_warning: bool = False) -> str | None: + cur = os.path.abspath(start_dir) + + while True: + candidate = os.path.join(cur, "issue-tracker") + if os.path.isdir(candidate): + return candidate + + parent = os.path.dirname(cur) + if parent == cur: # reached filesystem root + if not suppress_warning: + sys.stderr.write( + "xpit has not been initialized. `xpit init` to initialize.\n" + ) + return None + cur = parent + +def load_config() -> Err | None: + if state.dir is None: + state.dir = find_dir(os.getcwd()) + if state.dir is None: + return Err("ERR_COULD_NOT_FIND_ISSUE_TRACKER_DIR") + with open(os.path.join(state.dir, "_config.ini"), "rb") as f: + src = f.read() + state.config = {} + issue.parse_ini(src, 0, state.config) + if b"allowed_labels" not in state.config: + sys.stdout.write("Warning: _config.ini has no allowed_labels\n") + state.config[b"allowed_labels"] = b"" + if b"id_format" not in state.config: + sys.stdout.write("Warning: _config.ini has no id_format. Defaulting to '%Y%m%d_%H%M%S'\n") + state.config[b"id_format"] = b"%Y%m%d_%H%M%S" + state.allowed_labels = state.config[b"allowed_labels"].split(b',') + + return None + +def __getattr__(name): + return getattr(state, name) + diff --git a/src/xpit_/list_.py b/src/xpit_/list_.py new file mode 100644 index 0000000..fe281c2 --- /dev/null +++ b/src/xpit_/list_.py @@ -0,0 +1,104 @@ +import os +from sys import argv, stdout, stderr +from . import args_helpers, issue, issue_tracker + +DEFAULT_ORDER: list[bytes] = [b"ID", b"POST_COUNT", b"TITLE", b"status", b"labels"] + +help_text = f"""\ +Usage: xpit list [options] +Examples: + xpit list -d , -f TITLE,POST_COUNT +Options: + -d, --delimiter <str> + Delimiter used to separate the fields. + Defaults to '|' (pipe). + -f, --fields <FIELD,...> + Defaults to '{b','.join(DEFAULT_ORDER).decode("utf-8")}'. + Guaranteed fields: + ID - ID of the issue + TITLE - Title of the issue + AUTHOR - Author of the issue + POST_COUNT - Total number of posts in the issue, including the initial post + PATH_ABS - absolute path to issue.md file + PATH_REL - relative path to issue.md file + Other fields are optional and parsed in issues' INI headers. + The fields are printed in order and the same field can be printed more than once. + -h, --help + Print this help text. +""" + +class Args: + def __init__(self) -> None: + self.order = DEFAULT_ORDER + self.delimiter = '|' + + def parse(self) -> bool: + flag_value_map = { + "help|h": 0, + "delimiter|d": 1, + "fields|f": 1, + } + res = args_helpers.parse_generic(flag_value_map, argv, 2) + if res is None: + return False + if "help" in res: + stdout.write(help_text) + return False + if "delimiter" in res: + self.delimiter = res["delimiter"][0] + if "fields" in res: + self.order = [field.encode("utf-8") for field in res["fields"][0].split(',')] + + return True +args = Args() + +def main() -> int: + if len(argv) < 2: + stdout.write(help_text) + return 1 + + if not args.parse(): + return 1 + + cwd = os.getcwd() + issue_tracker_dir = issue_tracker.find_dir(cwd) + if issue_tracker_dir is None: + return 1 + for file_name in os.listdir(issue_tracker_dir): + if not os.path.isdir(os.path.join(issue_tracker_dir, file_name)): + continue + + file_path = os.path.join(issue_tracker_dir, file_name, "issue.md") + with open(file_path, "rb") as f: + src = f.read() + info, err = issue.parse(src) + if err is not None: + stderr.write(err.msg) + return 1 + i = 0 + while True: + key = args.order[i] + if key == b"ID": + stdout.write(file_name) + elif key == b"TITLE": + stdout.write(info.title.decode("utf-8")) + elif key == b"POST_COUNT": + stdout.write(str(info.post_count())) + elif key == b"AUTHOR": + stdout.write(info.meta[b"author"].decode("utf-8")) + elif key == b"PATH_ABS": + stdout.write(file_path) + elif key == b"PATH_REL": + stdout.write(os.path.relpath(file_path, start=cwd)) + else: + if key in info.meta: + stdout.write(info.meta[key].decode("utf-8")) + + i += 1 + if i >= len(args.order): + break + stdout.write(args.delimiter) + stdout.write('\n') + + return 0 + diff --git a/src/xpit_/xpit.py b/src/xpit_/xpit.py new file mode 100644 index 0000000..f6eefd0 --- /dev/null +++ b/src/xpit_/xpit.py @@ -0,0 +1,53 @@ +from sys import argv, stdout +from . import args_helpers, init, create, list_ + +help_text = """\ +xpit - cross-platform issue tracker +Usage: xpit <COMMAND> [options] +<COMMAND>: + init: + Initialize issue tracker in current directory. + create: + Create new issue or reply. + list: + List issues. +Options: + -h, --help + Print this help text. `xpit <COMMAND> -h` for help with specific command. +""" + +class Args: + def __init__(self) -> None: + pass + + def parse(self) -> bool: + flag_value_map = { + "help|h": 0, + } + res = args_helpers.parse_generic(flag_value_map, argv, 1) + if res is None: + return False + if "help" in res: + stdout.write(help_text) + return False + + return True +args = Args() + +def main() -> int: + if len(argv) <= 1: + stdout.write(help_text) + return 1 + + elif argv[1] == "init": + return init.main() + elif argv[1] == "create": + return create.main() + elif argv[1] == "list": + return list_.main() + else: + if not args.parse(): + return 1 + + return 0 + |
