diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..8713ba0 --- /dev/null +++ b/.flake8 @@ -0,0 +1,13 @@ +[flake8] +doctests = True +# compatibility with black +max-line-length = 88 +extend-ignore = + E203, +exclude = + .venv +# flake-docstrings +per-file-ignores = + tests/**:D + doc/**:D +docstring-convention = google diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..05db059 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,119 @@ +name: CI + +on: [push, pull_request] + +jobs: + + lint: + + runs-on: ubuntu-latest + + steps: + + - name: get repo + uses: actions/checkout@v3 + + - name: set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: install dependencies + run: | + pip install flake8 black isort docformatter + + - name: lint with flake8 + run: | + flake8 + + - name: lint with black + run: | + black --check . + + - name: lint with isort + run: | + isort --check . + + - name: lint with docformatter + run: | + docformatter --check . + + type-check: + + needs: lint + + runs-on: ubuntu-latest + + steps: + + - name: get repo + uses: actions/checkout@v3 + + - name: set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: install package + run: | + pip install .[img] mypy types-PyYAML + + - name: run mypy check + run: mypy src + + unit-test: + + needs: type-check + + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os : [ubuntu-latest, windows-latest, macos-latest] + python: ["3.8", "3.9", "3.10", "3.11", "3.12"] + + steps: + + - name: get repo + uses: actions/checkout@v3 + + - name: set up Python + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + + - name: install package + run: | + pip install .[test] + + - run: pytest + + doc: + + needs: unit-test + + runs-on: ubuntu-latest + + steps: + + - name: get repo + uses: actions/checkout@v3 + + - name: set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.12" + + - name: install package + run: | + pip install .[doc] doc8 + + - name: build document + run: | + cd doc + make html SPHINXOPTS="-W --keep-going" + + - name: lint with doc8 # doc8 test must be done after building doc once. + run: | + doc8 . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9847a4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,132 @@ +# Custom entries +doc/source/help-*.txt + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..36b880e --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,15 @@ +version: 2 + +build: + os: ubuntu-22.04 + tools: + python: "3.12" + +sphinx: + configuration: doc/source/conf.py + +python: + install: + - path: . + extra_requirements: + - doc diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..bc2b055 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include src/finitedepth/samples/* +include src/finitedepth/py.typed diff --git a/doc/Makefile b/doc/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/doc/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/doc/make.bat b/doc/make.bat new file mode 100644 index 0000000..dc1312a --- /dev/null +++ b/doc/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/doc/source/_templates/autoapi/index.rst b/doc/source/_templates/autoapi/index.rst new file mode 100644 index 0000000..b80279b --- /dev/null +++ b/doc/source/_templates/autoapi/index.rst @@ -0,0 +1,24 @@ +Reference guides +================ + +User reference +-------------- + +.. toctree:: + :titlesonly: + + ../command-reference + +.. _module-reference: + +Module reference +---------------- + +.. toctree:: + :titlesonly: + + {% for page in pages %} + {% if page.top_level_object and page.display %} + {{ page.include_path }} + {% endif %} + {% endfor %} diff --git a/doc/source/command-reference.rst b/doc/source/command-reference.rst new file mode 100644 index 0000000..5cf6c40 --- /dev/null +++ b/doc/source/command-reference.rst @@ -0,0 +1,19 @@ +.. _command-reference: + +Commands +======== + +.. literalinclude:: help-finitedepth.txt + :language: text + +samples +------- + +.. literalinclude:: help-finitedepth-samples.txt + :language: text + +analyze +------- + +.. literalinclude:: help-finitedepth-analyze.txt + :language: text diff --git a/doc/source/conf.py b/doc/source/conf.py new file mode 100644 index 0000000..07f3dbd --- /dev/null +++ b/doc/source/conf.py @@ -0,0 +1,81 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +import subprocess + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = "DipCoatImage-FiniteDepth" +copyright = "2024, Jisoo Song" +author = "Jisoo Song" + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + "sphinx.ext.napoleon", + "sphinx.ext.autodoc", + "sphinx.ext.viewcode", + "autoapi.extension", + "sphinx.ext.intersphinx", + "sphinx_tabs.tabs", + "matplotlib.sphinxext.plot_directive", +] + +templates_path = ["_templates"] +exclude_patterns = [] + +autodoc_typehints = "description" + +autoapi_dirs = ["../../src"] +autoapi_template_dir = "_templates/autoapi" +autoapi_root = "reference" + + +def autoapi_skip(app, what, name, obj, skip, options): + if what == "module" and name in [ + "finitedepth.__main__", + ]: + skip = True + return skip + + +def setup(sphinx): + sphinx.connect("autoapi-skip-member", autoapi_skip) + + +intersphinx_mapping = { + "python": ("http://docs.python.org/", None), + "pip": ("https://pip.pypa.io/en/stable/", None), + "numpy": ("https://numpy.org/doc/stable/", None), + "mypy": ("https://mypy.readthedocs.io/en/stable/", None), +} + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = "furo" +html_title = "DipCoatImage-FiniteDepth" +html_static_path = ["_static"] + +plot_html_show_formats = False +plot_html_show_source_link = False + +# -- Custom scripts ---------------------------------------------------------- + +# Reference file + +f = open("help-finitedepth.txt", "w") +subprocess.call(["finitedepth", "-h"], stdout=f) +f.close() + +f = open("help-finitedepth-samples.txt", "w") +subprocess.call(["finitedepth", "samples", "-h"], stdout=f) +f.close() + +f = open("help-finitedepth-analyze.txt", "w") +subprocess.call(["finitedepth", "analyze", "-h"], stdout=f) +f.close() diff --git a/doc/source/index.rst b/doc/source/index.rst new file mode 100644 index 0000000..da5ee46 --- /dev/null +++ b/doc/source/index.rst @@ -0,0 +1,20 @@ +.. DipCoatImage-FiniteDepth documentation master file, created by + sphinx-quickstart on Sat Mar 2 19:36:13 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +Welcome to DipCoatImage-FiniteDepth's documentation! +==================================================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + + reference/index + +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9edb2d5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,103 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "dipcoatimage-finitedepth" +version = "2.0.0" +authors = [ + {name = "Jisoo Song", email = "jeesoo9595@snu.ac.kr"} +] +description = "Image analysis for finite depth dip coating process" +readme = "README.md" +requires-python = ">=3.9" +license = {file = "LICENSE"} +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: BSD License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Scientific/Engineering", + "Topic :: Scientific/Engineering :: Physics", + "Typing :: Typed", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX", + "Operating System :: Unix", + "Operating System :: MacOS", +] +dynamic = [ + "dependencies", +] + +[project.urls] +homepage = "https://github.com/dipcoat-image/finitedepth" +source = "https://github.com/dipcoat-image/finitedepth" +documentation = "https://dipcoatimage-finitedepth.readthedocs.io" + +[project.scripts] +finitedepth = "finitedepth:main" + +[project.optional-dependencies] +test = [ + "pytest", +] +doc = [ + "furo", + "sphinx", + "sphinx-autoapi", + "sphinx-tabs >= 3.4.5", + "matplotlib", + "pandas", +] +dev = [ + "flake8", + "flake8-docstrings", + "black", + "isort", + "docformatter", + "doc8", + "mypy", + "types-PyYAML", + "dipcoatimage-finitedepth[test,doc]", +] + +[tool.setuptools.dynamic] +dependencies = {file = ["requirements.txt"]} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.isort] +profile = "black" + +[tool.docformatter] +recursive = true +in-place = true +black = true + +[tool.doc8] +ignore = ["D004"] +ignore-path = [ + "src", + "doc/build", +] + +[tool.mypy] +namespace_packages = true +explicit_package_bases = true +exclude = ["build"] +plugins = ["numpy.typing.mypy_plugin"] + +[tool.pytest.ini_options] +doctest_optionflags = [ + "NORMALIZE_WHITESPACE", + "IGNORE_EXCEPTION_DETAIL", + "ELLIPSIS", +] +addopts = "--ignore-glob=doc/**/*.py --doctest-modules --doctest-glob=*.rst" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c63dfbc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +importlib_metadata;python_version<"3.10" +importlib_resources;python_version<"3.10" +numpy +PyYAML diff --git a/src/finitedepth/__init__.py b/src/finitedepth/__init__.py new file mode 100644 index 0000000..bfb0316 --- /dev/null +++ b/src/finitedepth/__init__.py @@ -0,0 +1,234 @@ +"""Package for image analysis of finite depth dip coating. + +To analyze with command line, specify the parameters in configuration file(s) and run:: + + finitedepth analyze [ ...] +""" + +import argparse +import glob +import json +import logging +import os +import re +import sys + +import yaml + +if sys.version_info < (3, 10): + from importlib_metadata import entry_points + from importlib_resources import files +else: + from importlib.metadata import entry_points + from importlib.resources import files + +__all__ = [ + "get_sample_path", + "analyze_files", +] + + +def get_sample_path(*paths: str) -> str: + """Get path to sample file. + + Arguments: + paths: Subpaths under ``finitedepth/samples/`` directory. + + Returns: + Absolute path to the sample file. + + Examples: + >>> from finitedepth import get_sample_path + >>> get_sample_path() # doctest: +SKIP + 'path/finitedepth/samples' + >>> get_sample_path("myfile") # doctest: +SKIP + 'path/finitedepth/samples/myfile' + """ + return str(files("finitedepth").joinpath("samples", *paths)) + + +def analyze_files( + *paths: str, recursive: bool = False, entries: list[str] | None = None +) -> bool: + """Perform analysis from configuration files. + + Supported formats: + * YAML + * JSON + + Each file can have multiple entries. Each entry must have ``type`` field which + specifies the analyzer. For example, the following YAML file contains ``foo`` + entry which is analyzed by ``Foo`` analyzer. + + .. code-block:: yaml + + foo: + type: Foo + ... + + Analyzers are searched and loaded from entry point group + ``"finitedepth.analyzers"``, and must have the following signature: + + * entry name (:obj:`str`) + * entry fields (:obj:`dict`) + + Arguments: + paths: Glob pattern for configuration file paths. + recursive: If True, search *paths* recursively. + entries: Regular expression for entries. + If passed, only the matching entries are analyzed. + + Returns: + Whether the analysis is finished without error. + """ + # load analyzers + ANALYZERS = {} + for ep in entry_points(group="finitedepth.analyzers"): + ANALYZERS[ep.name] = ep + + glob_paths = [] + for path in paths: + glob_paths.extend(glob.glob(os.path.expandvars(path), recursive=recursive)) + + if entries is not None: + entry_patterns = [re.compile(e) for e in entries] + else: + entry_patterns = [] + + ok = True + for path in glob_paths: + _, ext = os.path.splitext(path) + ext = ext.lstrip(os.path.extsep).lower() + try: + with open(path, "r") as f: + if ext == "yaml" or ext == "yml": + data = yaml.load(f, Loader=yaml.FullLoader) + elif ext == "json": + data = json.load(f) + else: + logging.error(f"Skipping file: '{path}' (format not supported)") + ok = False + continue + except FileNotFoundError: + logging.error(f"Skipping file: '{path}' (does not exist)") + ok = False + continue + for k, v in data.items(): + if entry_patterns and all([p.fullmatch(k) is None for p in entry_patterns]): + continue + try: + typename = v["type"] + analyzer = ANALYZERS.get(typename, None) + if analyzer is not None: + analyzer.load()(k, v) + else: + logging.error( + f"Skipping entry: '{path}::{k}' (unknown type: '{typename}')" + ) + except Exception: + logging.exception(f"Skipping entry: '{path}::{k}' (exception raised)") + ok = False + continue + return ok + + +def main(): + """Entry point function.""" + parser = argparse.ArgumentParser( + prog="finitedepth", + description="Finite depth dip coating image analysis.", + ) + parser.add_argument( + "--log-level", + choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"], + default="WARNING", + help="set logging level", + ) + subparsers = parser.add_subparsers(dest="command") + + samples = subparsers.add_parser( + "samples", + description="Print path to sample directory.", + help="print path to sample directory", + ).add_mutually_exclusive_group() + samples.add_argument( + "plugin", + type=str, + nargs="?", + help="name of the plugin", + ) + samples.add_argument( + "-l", + "--list", + action="store_true", + help="list plugin names", + ) + + analyze = subparsers.add_parser( + "analyze", + description="Parse configuration files and analyze.", + help="parse configuration files and analyze", + epilog=( + "Supported file formats: YAML, JSON.\n" + "Refer to the package documentation for configuration file structure." + ), + ) + analyze.add_argument( + "file", type=str, nargs="+", help="glob pattern for configuration files" + ) + analyze.add_argument( + "-r", + "--recursive", + action="store_true", + help="recursively find configuration files", + ) + analyze.add_argument( + "-e", + "--entry", + action="append", + help="regex pattern for configuration file entries", + ) + + args = parser.parse_args() + + loglevel = args.log_level.upper() + logging.basicConfig( + format="[%(asctime)s] [%(levelname)8s] --- %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + level=loglevel, + ) + + logging.debug(f"Input command: {' '.join(sys.argv)}") + + if args.command is None: + parser.print_help(sys.stderr) + sys.exit(1) + elif args.command == "samples": + if args.list: + header = [("PLUGIN", "PATH")] + paths = [ + (ep.name, ep.load()()) + for ep in entry_points(group="finitedepth.samples") + ] + col0_max = max(len(p[0]) for p in header + paths) + space = 3 + for col0, col1 in header + paths: + line = col0.ljust(col0_max) + " " * space + col1 + print(line) + elif args.plugin is None: + print(get_sample_path()) + else: + for ep in entry_points(group="finitedepth.samples"): + if ep.name == args.plugin: + getter = ep.load() + print(getter()) + break + else: + logging.error( + f"Unknown plugin: '{args.plugin}' (use '-l' option to list plugins)" + ) + sys.exit(1) + elif args.command == "analyze": + ok = analyze_files(*args.file, recursive=args.recursive, entries=args.entry) + if not ok: + sys.exit(1) diff --git a/src/finitedepth/__main__.py b/src/finitedepth/__main__.py new file mode 100644 index 0000000..d4393f8 --- /dev/null +++ b/src/finitedepth/__main__.py @@ -0,0 +1,6 @@ +"""Entry point script.""" + +from . import main + +if __name__ == "__main__": + main() diff --git a/src/finitedepth/py.typed b/src/finitedepth/py.typed new file mode 100644 index 0000000..1242d43 --- /dev/null +++ b/src/finitedepth/py.typed @@ -0,0 +1 @@ +# Marker file for PEP 561. diff --git a/src/finitedepth/samples/coat.mp4 b/src/finitedepth/samples/coat.mp4 new file mode 100644 index 0000000..56eec31 Binary files /dev/null and b/src/finitedepth/samples/coat.mp4 differ diff --git a/src/finitedepth/samples/ref.png b/src/finitedepth/samples/ref.png new file mode 100644 index 0000000..2137258 Binary files /dev/null and b/src/finitedepth/samples/ref.png differ