summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSteven Van Dorp <steven@vandorp.lu>2026-02-01 15:13:01 +0100
committerSteven Van Dorp <steven@vandorp.lu>2026-02-01 15:13:01 +0100
commitc7987858924608f8013e64260efb806e5ddeab9f (patch)
tree06d1a0e487cabf896339386d2c1ecf02f87a540e
Initial commitHEADmaster
-rw-r--r--.gitignore3
-rw-r--r--.pylintrc664
-rw-r--r--issue-tracker/20260201_140007/issue.md10
-rw-r--r--issue-tracker/20260201_140421/issue.md17
-rw-r--r--issue-tracker/_config.ini3
-rw-r--r--mypy.ini10
-rwxr-xr-xsrc/xpit8
-rw-r--r--src/xpit_/args_helpers.py102
-rw-r--r--src/xpit_/create.py263
-rw-r--r--src/xpit_/error.py4
-rw-r--r--src/xpit_/identity.py59
-rw-r--r--src/xpit_/init.py54
-rw-r--r--src/xpit_/issue.py168
-rw-r--r--src/xpit_/issue_tracker.py52
-rw-r--r--src/xpit_/list_.py104
-rw-r--r--src/xpit_/xpit.py53
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
+