├── tests ├── __init__.py ├── utils │ ├── __init__.py │ ├── test_bunch.py │ ├── test_importstring.py │ └── test_decorators.py ├── config │ ├── __init__.py │ └── test_argcomplete.py ├── test_traitlets_docstring.py ├── _warnings.py └── test_traitlets_enum.py ├── traitlets ├── py.typed ├── tests │ ├── __init__.py │ ├── utils.py │ └── test_traitlets.py ├── config │ ├── __init__.py │ ├── manager.py │ ├── sphinxdoc.py │ └── argcomplete_config.py ├── _version.py ├── utils │ ├── sentinel.py │ ├── bunch.py │ ├── nested_update.py │ ├── importstring.py │ ├── text.py │ ├── getargspec.py │ ├── warnings.py │ ├── decorators.py │ ├── __init__.py │ └── descriptions.py ├── log.py └── __init__.py ├── CHANGES.txt ├── .git-blame-ignore-revs ├── CONTRIBUTING.md ├── examples ├── docs │ ├── configs │ │ ├── base_config.py │ │ └── main_config.py │ ├── container.py │ ├── aliases.py │ ├── multiple_apps.py │ ├── from_string.py │ ├── subcommands.py │ ├── flags.py │ └── load_config_app.py ├── subcommands_app.py ├── argcomplete_app.py └── myapp.py ├── .readthedocs.yml ├── .gitignore ├── SECURITY.md ├── .github ├── workflows │ ├── enforce-label.yml │ ├── publish-changelog.yml │ ├── prep-release.yml │ ├── publish-release.yml │ ├── downstream.yml │ └── tests.yml └── dependabot.yml ├── docs ├── source │ ├── config-api.rst │ ├── index.rst │ ├── defining_traits.rst │ ├── utils.rst │ ├── trait_types.rst │ ├── api.rst │ ├── using_traitlets.rst │ ├── migration.rst │ └── conf.py ├── readme-docs.md ├── sphinxext │ └── github.py ├── Makefile └── make.bat ├── RELEASE.md ├── LICENSE ├── .pre-commit-config.yaml ├── README.md ├── pyproject.toml └── .mailmap /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /traitlets/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /traitlets/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /CHANGES.txt: -------------------------------------------------------------------------------- 1 | The changes are at "./docs/source/changelog.rst". 2 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # Initial pre-commit reformat 2 | 660aeaf51fdf4df1b696faed681819de246ad081 3 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | We follow the [IPython Contributing Guide](https://github.com/ipython/ipython/blob/master/CONTRIBUTING.md). 4 | -------------------------------------------------------------------------------- /examples/docs/configs/base_config.py: -------------------------------------------------------------------------------- 1 | # Example config used by load_config_app.py 2 | from __future__ import annotations 3 | 4 | c = get_config() # noqa: F821 5 | c.MyClass.name = "Harvard" 6 | c.MyClass.ranking = 100 7 | -------------------------------------------------------------------------------- /examples/docs/configs/main_config.py: -------------------------------------------------------------------------------- 1 | # Example config used by load_config_app.py 2 | from __future__ import annotations 3 | 4 | c = get_config() # noqa: F821 5 | 6 | # Load everything from base_config.py 7 | load_subconfig("base_config.py") # noqa: F821 8 | 9 | # Now override one of the values 10 | c.School.name = "Caltech" 11 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | sphinx: 3 | configuration: docs/source/conf.py 4 | formats: 5 | - epub 6 | - pdf 7 | python: 8 | install: 9 | # install itself with pip install . 10 | - method: pip 11 | path: . 12 | extra_requirements: 13 | - docs 14 | build: 15 | os: ubuntu-22.04 16 | tools: 17 | python: "3.11" 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | build 3 | dist 4 | _build 5 | docs/man/*.gz 6 | docs/gh-pages 7 | IPython/html/notebook/static/mathjax 8 | IPython/html/static/style/*.map 9 | *.py[co] 10 | __pycache__ 11 | *.egg-info 12 | *~ 13 | *.bak 14 | .ipynb_checkpoints 15 | .tox 16 | .DS_Store 17 | \#*# 18 | .#* 19 | .coverage 20 | .cache 21 | htmlcov 22 | docs/source/CHANGELOG.md 23 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | All IPython and Jupyter security are handled via security@ipython.org. 6 | You can find more information on the Jupyter website. https://jupyter.org/security 7 | 8 | ## Tidelift 9 | 10 | We are also lifting IPython via Tidelift, you can also report security concern via the [tidelift platform](https://tidelift.com/security). 11 | -------------------------------------------------------------------------------- /.github/workflows/enforce-label.yml: -------------------------------------------------------------------------------- 1 | name: Enforce PR label 2 | 3 | on: 4 | pull_request: 5 | types: [labeled, unlabeled, opened, edited, synchronize] 6 | jobs: 7 | enforce-label: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | pull-requests: write 11 | steps: 12 | - name: enforce-triage-label 13 | uses: jupyterlab/maintainer-tools/.github/actions/enforce-label@v1 14 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | groups: 8 | actions: 9 | patterns: 10 | - "*" 11 | - package-ecosystem: "pip" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | groups: 16 | actions: 17 | patterns: 18 | - "*" 19 | -------------------------------------------------------------------------------- /tests/utils/test_bunch.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from traitlets.utils.bunch import Bunch 4 | 5 | 6 | def test_bunch(): 7 | b = Bunch(x=5, y=10) 8 | assert "y" in b 9 | assert "x" in b 10 | assert b.x == 5 11 | b["a"] = "hi" 12 | assert b.a == "hi" 13 | 14 | 15 | def test_bunch_dir(): 16 | b = Bunch(x=5, y=10) 17 | assert "keys" in dir(b) 18 | assert "x" in dir(b) 19 | assert "z" not in dir(b) 20 | b.z = 15 21 | assert "z" in dir(b) 22 | -------------------------------------------------------------------------------- /traitlets/config/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) IPython Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | from __future__ import annotations 4 | 5 | from .application import * 6 | from .configurable import * 7 | from .loader import Config 8 | 9 | __all__ = [ # noqa: F405 10 | "Config", 11 | "Application", 12 | "ApplicationError", 13 | "LevelFormatter", 14 | "configurable", 15 | "Configurable", 16 | "ConfigurableError", 17 | "MultipleInstanceError", 18 | "LoggingConfigurable", 19 | "SingletonConfigurable", 20 | ] 21 | -------------------------------------------------------------------------------- /traitlets/_version.py: -------------------------------------------------------------------------------- 1 | """ 2 | handle the current version info of traitlets. 3 | """ 4 | from __future__ import annotations 5 | 6 | import re 7 | 8 | # Version string must appear intact for hatch versioning 9 | __version__ = "5.14.3" 10 | 11 | # Build up version_info tuple for backwards compatibility 12 | pattern = r"(?P\d+).(?P\d+).(?P\d+)(?P.*)" 13 | match = re.match(pattern, __version__) 14 | assert match is not None 15 | parts: list[object] = [int(match[part]) for part in ["major", "minor", "patch"]] 16 | if match["rest"]: 17 | parts.append(match["rest"]) 18 | version_info = tuple(parts) 19 | -------------------------------------------------------------------------------- /examples/docs/container.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | """A simple example of using container traits in Application command-line""" 4 | from __future__ import annotations 5 | 6 | from traitlets import Dict, Integer, List, Unicode 7 | from traitlets.config import Application 8 | 9 | 10 | class App(Application): 11 | aliases = {"x": "App.x", "y": "App.y"} 12 | x = List(Unicode(), config=True) 13 | y = Dict(Integer(), config=True) 14 | 15 | def start(self): 16 | print(f"x={self.x}") 17 | print(f"y={self.y}") 18 | 19 | 20 | if __name__ == "__main__": 21 | App.launch_instance() 22 | -------------------------------------------------------------------------------- /docs/source/config-api.rst: -------------------------------------------------------------------------------- 1 | Traitlets config API reference 2 | ============================== 3 | 4 | .. currentmodule:: traitlets.config 5 | 6 | .. autoclass:: Configurable 7 | :members: 8 | 9 | .. autoclass:: SingletonConfigurable 10 | :members: 11 | 12 | .. autoclass:: LoggingConfigurable 13 | :members: 14 | 15 | .. autoclass:: JSONFileConfigLoader 16 | :members: 17 | 18 | .. autoclass:: Application 19 | :members: 20 | 21 | .. autoclass:: Config 22 | :members: 23 | 24 | 25 | .. autoclass:: traitlets.config.loader.LazyConfigValue 26 | :members: 27 | 28 | .. autoclass:: traitlets.config.loader.KVArgParseConfigLoader 29 | :members: __init__, load_config 30 | -------------------------------------------------------------------------------- /examples/docs/aliases.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | """A simple example of using Application aliases, for docs""" 4 | from __future__ import annotations 5 | 6 | from traitlets import Bool 7 | from traitlets.config import Application, Configurable 8 | 9 | 10 | class Foo(Configurable): 11 | enabled = Bool(False, help="whether enabled").tag(config=True) 12 | 13 | 14 | class App(Application): 15 | classes = [Foo] 16 | dry_run = Bool(False, help="dry run test").tag(config=True) 17 | aliases = { 18 | "dry-run": "App.dry_run", 19 | ("f", "foo-enabled"): ("Foo.enabled", "whether foo is enabled"), 20 | } 21 | 22 | 23 | if __name__ == "__main__": 24 | App.launch_instance() 25 | -------------------------------------------------------------------------------- /examples/docs/multiple_apps.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | """A simple example of one application calling another""" 4 | from __future__ import annotations 5 | 6 | from traitlets.config import Application 7 | 8 | 9 | class OtherApp(Application): 10 | def start(self): 11 | print("other") 12 | 13 | 14 | class MyApp(Application): 15 | classes = [OtherApp] 16 | 17 | def start(self): 18 | # similar to OtherApp.launch_instance(), but without singleton 19 | self.other_app = OtherApp(config=self.config) 20 | self.other_app.initialize(["--OtherApp.log_level", "INFO"]) 21 | self.other_app.start() 22 | 23 | 24 | if __name__ == "__main__": 25 | MyApp.launch_instance() 26 | -------------------------------------------------------------------------------- /traitlets/utils/sentinel.py: -------------------------------------------------------------------------------- 1 | """Sentinel class for constants with useful reprs""" 2 | 3 | # Copyright (c) IPython Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import typing as t 8 | 9 | 10 | class Sentinel: 11 | def __init__(self, name: str, module: t.Any, docstring: str | None = None) -> None: 12 | self.name = name 13 | self.module = module 14 | if docstring: 15 | self.__doc__ = docstring 16 | 17 | def __repr__(self) -> str: 18 | return str(self.module) + "." + self.name 19 | 20 | def __copy__(self) -> Sentinel: 21 | return self 22 | 23 | def __deepcopy__(self, memo: t.Any) -> Sentinel: 24 | return self 25 | -------------------------------------------------------------------------------- /examples/docs/from_string.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | """A simple example of using TraitType.from_string, for docs""" 4 | from __future__ import annotations 5 | 6 | from binascii import a2b_hex 7 | 8 | from traitlets import Bytes 9 | from traitlets.config import Application 10 | 11 | 12 | class HexBytes(Bytes): 13 | def from_string(self, s): 14 | return a2b_hex(s) 15 | 16 | 17 | class App(Application): 18 | aliases = {"key": "App.key"} 19 | key = HexBytes( 20 | help=""" 21 | Key to be used. 22 | 23 | Specify as hex on the command-line. 24 | """, 25 | config=True, 26 | ) 27 | 28 | def start(self): 29 | print(f"key={self.key.decode('utf8')}") 30 | 31 | 32 | if __name__ == "__main__": 33 | App.launch_instance() 34 | -------------------------------------------------------------------------------- /examples/docs/subcommands.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | """A simple example of using Application subcommands, for docs""" 4 | from __future__ import annotations 5 | 6 | from traitlets.config import Application 7 | 8 | 9 | class SubApp1(Application): 10 | pass 11 | 12 | 13 | class SubApp2(Application): 14 | @classmethod 15 | def get_subapp_instance(cls, app: Application) -> Application: 16 | app.clear_instance() # since Application is singleton, need to clear main app 17 | return cls.instance(parent=app) # type: ignore[no-any-return] 18 | 19 | 20 | class MainApp(Application): 21 | subcommands = { 22 | "subapp1": (SubApp1, "First subapp"), 23 | "subapp2": (SubApp2.get_subapp_instance, "Second subapp"), 24 | } 25 | 26 | 27 | if __name__ == "__main__": 28 | MainApp.launch_instance() 29 | -------------------------------------------------------------------------------- /traitlets/utils/bunch.py: -------------------------------------------------------------------------------- 1 | """Yet another implementation of bunch 2 | 3 | attribute-access of items on a dict. 4 | """ 5 | 6 | # Copyright (c) Jupyter Development Team. 7 | # Distributed under the terms of the Modified BSD License. 8 | from __future__ import annotations 9 | 10 | from typing import Any 11 | 12 | 13 | class Bunch(dict): # type:ignore[type-arg] 14 | """A dict with attribute-access""" 15 | 16 | def __getattr__(self, key: str) -> Any: 17 | try: 18 | return self.__getitem__(key) 19 | except KeyError as e: 20 | raise AttributeError(key) from e 21 | 22 | def __setattr__(self, key: str, value: Any) -> None: 23 | self.__setitem__(key, value) 24 | 25 | def __dir__(self) -> list[str]: 26 | names: list[str] = [] 27 | names.extend(super().__dir__()) 28 | names.extend(self.keys()) 29 | return names 30 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | Traitlets 2 | ========= 3 | 4 | :Release: |release| 5 | :Date: |today| 6 | :home: https://github.com/ipython/traitlets 7 | :pypi-repo: https://pypi.org/project/traitlets/ 8 | :docs: https://traitlets.readthedocs.io/ 9 | :license: Modified BSD License 10 | 11 | Traitlets is a framework that lets Python classes have attributes with type 12 | checking, dynamically calculated default values, and 'on change' callbacks. 13 | 14 | The package also includes a mechanism to use traitlets for configuration, 15 | loading values from files or from command line arguments. This is a distinct 16 | layer on top of traitlets, so you can use traitlets in your code without using 17 | the configuration machinery. 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | 22 | using_traitlets 23 | trait_types 24 | defining_traits 25 | api 26 | config 27 | config-api 28 | utils 29 | migration 30 | changelog 31 | -------------------------------------------------------------------------------- /examples/docs/flags.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | """A simple example of using Application flags, for docs""" 4 | from __future__ import annotations 5 | 6 | from traitlets import Bool 7 | from traitlets.config import Application, Configurable 8 | 9 | 10 | class Foo(Configurable): 11 | enabled = Bool(False, help="whether enabled").tag(config=True) 12 | 13 | 14 | class App(Application): 15 | classes = [Foo] 16 | dry_run = Bool(False, help="dry run test").tag(config=True) 17 | flags = { 18 | "dry-run": ({"App": {"dry_run": True}}, dry_run.help), 19 | ("f", "enable-foo"): ( 20 | { 21 | "Foo": {"enabled": True}, 22 | }, 23 | "Enable foo", 24 | ), 25 | ("disable-foo"): ( 26 | { 27 | "Foo": {"enabled": False}, 28 | }, 29 | "Disable foo", 30 | ), 31 | } 32 | 33 | 34 | if __name__ == "__main__": 35 | App.launch_instance() 36 | -------------------------------------------------------------------------------- /tests/utils/test_importstring.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) IPython Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | # 4 | # Adapted from enthought.traits, Copyright (c) Enthought, Inc., 5 | # also under the terms of the Modified BSD License. 6 | """Tests for traitlets.utils.importstring.""" 7 | from __future__ import annotations 8 | 9 | import os 10 | from unittest import TestCase 11 | 12 | from traitlets.utils.importstring import import_item 13 | 14 | 15 | class TestImportItem(TestCase): 16 | def test_import_unicode(self): 17 | self.assertIs(os, import_item("os")) 18 | self.assertIs(os.path, import_item("os.path")) 19 | self.assertIs(os.path.join, import_item("os.path.join")) 20 | 21 | def test_bad_input(self): 22 | class NotAString: 23 | pass 24 | 25 | msg = "import_item accepts strings, not '%s'." % NotAString 26 | with self.assertRaisesRegex(TypeError, msg): 27 | import_item(NotAString()) # type:ignore[arg-type] 28 | -------------------------------------------------------------------------------- /traitlets/log.py: -------------------------------------------------------------------------------- 1 | """Grab the global logger instance.""" 2 | 3 | # Copyright (c) IPython Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import logging 8 | from typing import Any 9 | 10 | _logger: logging.Logger | logging.LoggerAdapter[Any] | None = None 11 | 12 | 13 | def get_logger() -> logging.Logger | logging.LoggerAdapter[Any]: 14 | """Grab the global logger instance. 15 | 16 | If a global Application is instantiated, grab its logger. 17 | Otherwise, grab the root logger. 18 | """ 19 | global _logger # noqa: PLW0603 20 | 21 | if _logger is None: 22 | from .config import Application 23 | 24 | if Application.initialized(): 25 | _logger = Application.instance().log 26 | else: 27 | _logger = logging.getLogger("traitlets") 28 | # Add a NullHandler to silence warnings about not being 29 | # initialized, per best practice for libraries. 30 | _logger.addHandler(logging.NullHandler()) 31 | return _logger 32 | -------------------------------------------------------------------------------- /.github/workflows/publish-changelog.yml: -------------------------------------------------------------------------------- 1 | name: "Publish Changelog" 2 | on: 3 | release: 4 | types: [published] 5 | 6 | workflow_dispatch: 7 | inputs: 8 | branch: 9 | description: "The branch to target" 10 | required: false 11 | 12 | jobs: 13 | publish_changelog: 14 | runs-on: ubuntu-latest 15 | environment: release 16 | steps: 17 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 18 | 19 | - uses: actions/create-github-app-token@v1 20 | id: app-token 21 | with: 22 | app-id: ${{ vars.APP_ID }} 23 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 24 | 25 | - name: Publish changelog 26 | id: publish-changelog 27 | uses: jupyter-server/jupyter_releaser/.github/actions/publish-changelog@v2 28 | with: 29 | token: ${{ steps.app-token.outputs.token }} 30 | branch: ${{ github.event.inputs.branch }} 31 | 32 | - name: "** Next Step **" 33 | run: | 34 | echo "Merge the changelog update PR: ${{ steps.publish-changelog.outputs.pr_url }}" 35 | -------------------------------------------------------------------------------- /traitlets/__init__.py: -------------------------------------------------------------------------------- 1 | """Traitlets Python configuration system""" 2 | from __future__ import annotations 3 | 4 | import typing as _t 5 | 6 | from . import traitlets 7 | from ._version import __version__, version_info 8 | from .traitlets import * 9 | from .utils.bunch import Bunch 10 | from .utils.decorators import signature_has_traits 11 | from .utils.importstring import import_item 12 | from .utils.warnings import warn 13 | 14 | __all__ = [ 15 | "traitlets", 16 | "__version__", 17 | "version_info", 18 | "Bunch", 19 | "signature_has_traits", 20 | "import_item", 21 | "Sentinel", 22 | ] 23 | 24 | 25 | class Sentinel(traitlets.Sentinel): # type:ignore[name-defined, misc] 26 | def __init__(self, *args: _t.Any, **kwargs: _t.Any) -> None: 27 | super().__init__(*args, **kwargs) 28 | warn( 29 | """ 30 | Sentinel is not a public part of the traitlets API. 31 | It was published by mistake, and may be removed in the future. 32 | """, 33 | DeprecationWarning, 34 | stacklevel=2, 35 | ) 36 | -------------------------------------------------------------------------------- /docs/source/defining_traits.rst: -------------------------------------------------------------------------------- 1 | Defining new trait types 2 | ======================== 3 | 4 | .. py:currentmodule:: traitlets 5 | 6 | To define a new trait type, subclass from :class:`TraitType`. You can define the 7 | following things: 8 | 9 | .. class:: MyTrait 10 | 11 | .. attribute:: info_text 12 | 13 | A short string describing what this trait should hold. 14 | 15 | .. attribute:: default_value 16 | 17 | A default value, if one makes sense for this trait type. If there is no 18 | obvious default, don't provide this. 19 | 20 | .. method:: validate(obj, value) 21 | 22 | Check whether a given value is valid. If it is, it should return the value 23 | (coerced to the desired type, if necessary). If not, it should raise 24 | :exc:`TraitError`. :meth:`TraitType.error` is a convenient way to raise an 25 | descriptive error saying that the given value is not of the required type. 26 | 27 | ``obj`` is the object to which the trait belongs. 28 | 29 | For instance, here's the definition of the :class:`TCPAddress` trait: 30 | 31 | .. literalinclude:: /../../traitlets/traitlets.py 32 | :pyobject: TCPAddress 33 | -------------------------------------------------------------------------------- /docs/source/utils.rst: -------------------------------------------------------------------------------- 1 | Utils 2 | ===== 3 | 4 | .. module:: traitlets 5 | :noindex: 6 | 7 | A simple utility to import something by its string name. 8 | 9 | .. autofunction:: import_item 10 | 11 | .. autofunction:: signature_has_traits 12 | 13 | This is a way to expand the signature of the ``HasTraits`` class constructor. This 14 | enables auto-completion of trait-names in IPython and xeus-python when having 15 | Jedi>=0.15 by adding trait names with their default values in the constructor 16 | signature. 17 | 18 | Example: 19 | 20 | .. code:: Python 21 | 22 | from inspect import signature 23 | 24 | from traitlets import HasTraits, Int, Unicode, signature_has_traits 25 | 26 | @signature_has_traits 27 | class Foo(HasTraits): 28 | number1 = Int() 29 | number2 = Int() 30 | value = Unicode('Hello') 31 | 32 | def __init__(self, arg1, **kwargs): 33 | self.arg1 = arg1 34 | 35 | super(Foo, self).__init__(**kwargs) 36 | 37 | print(signature(Foo)) # 38 | 39 | 40 | Links 41 | ----- 42 | 43 | .. autoclass:: link 44 | 45 | .. autoclass:: directional_link 46 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Making a Pytest-Jupyter Release 2 | 3 | ## Using `jupyter_releaser` 4 | 5 | The recommended way to make a release is to use [`jupyter_releaser`](https://jupyter-releaser.readthedocs.io/en/latest/get_started/making_release_from_repo.html). 6 | 7 | Note that we must use manual versions since Jupyter Releaser does not 8 | yet support "next" or "patch" when dev versions are used. 9 | 10 | ## Manual Release 11 | 12 | To create a manual release, perform the following steps: 13 | 14 | ### Set up 15 | 16 | ```bash 17 | pip install hatch twine build 18 | git pull origin $(git branch --show-current) 19 | git clean -dffx 20 | ``` 21 | 22 | ### Update the version and apply the tag 23 | 24 | ```bash 25 | echo "Enter new version" 26 | read new_version 27 | hatch version ${new_version} 28 | git tag -a ${new_version} -m "Release ${new_version}" 29 | ``` 30 | 31 | ### Build the artifacts 32 | 33 | ```bash 34 | rm -rf dist 35 | python -m build . 36 | ``` 37 | 38 | ### Update the version back to dev 39 | 40 | ```bash 41 | echo "Enter dev version" 42 | read dev_version 43 | hatch version ${dev_version} 44 | git push origin $(git branch --show-current) 45 | ``` 46 | 47 | ### Publish the artifacts to pypi 48 | 49 | ```bash 50 | twine check dist/* 51 | twine upload dist/* 52 | ``` 53 | -------------------------------------------------------------------------------- /traitlets/utils/nested_update.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) IPython Development Team. 2 | # Distributed under the terms of the Modified BSD License. 3 | from __future__ import annotations 4 | 5 | from typing import Any 6 | 7 | 8 | def nested_update(this: dict[Any, Any], that: dict[Any, Any]) -> dict[Any, Any]: 9 | """Merge two nested dictionaries. 10 | 11 | Effectively a recursive ``dict.update``. 12 | 13 | Examples 14 | -------- 15 | Merge two flat dictionaries: 16 | >>> nested_update( 17 | ... {'a': 1, 'b': 2}, 18 | ... {'b': 3, 'c': 4} 19 | ... ) 20 | {'a': 1, 'b': 3, 'c': 4} 21 | 22 | Merge two nested dictionaries: 23 | >>> nested_update( 24 | ... {'x': {'a': 1, 'b': 2}, 'y': 5, 'z': 6}, 25 | ... {'x': {'b': 3, 'c': 4}, 'z': 7, '0': 8}, 26 | ... ) 27 | {'x': {'a': 1, 'b': 3, 'c': 4}, 'y': 5, 'z': 7, '0': 8} 28 | 29 | """ 30 | for key, value in this.items(): 31 | if isinstance(value, dict): 32 | if key in that and isinstance(that[key], dict): 33 | nested_update(this[key], that[key]) 34 | elif key in that: 35 | this[key] = that[key] 36 | 37 | for key, value in that.items(): 38 | if key not in this: 39 | this[key] = value 40 | 41 | return this 42 | -------------------------------------------------------------------------------- /traitlets/utils/importstring.py: -------------------------------------------------------------------------------- 1 | """ 2 | A simple utility to import something by its string name. 3 | """ 4 | # Copyright (c) IPython Development Team. 5 | # Distributed under the terms of the Modified BSD License. 6 | from __future__ import annotations 7 | 8 | from typing import Any 9 | 10 | 11 | def import_item(name: str) -> Any: 12 | """Import and return ``bar`` given the string ``foo.bar``. 13 | 14 | Calling ``bar = import_item("foo.bar")`` is the functional equivalent of 15 | executing the code ``from foo import bar``. 16 | 17 | Parameters 18 | ---------- 19 | name : string 20 | The fully qualified name of the module/package being imported. 21 | 22 | Returns 23 | ------- 24 | mod : module object 25 | The module that was imported. 26 | """ 27 | if not isinstance(name, str): 28 | raise TypeError("import_item accepts strings, not '%s'." % type(name)) 29 | parts = name.rsplit(".", 1) 30 | if len(parts) == 2: 31 | # called with 'foo.bar....' 32 | package, obj = parts 33 | module = __import__(package, fromlist=[obj]) 34 | try: 35 | pak = getattr(module, obj) 36 | except AttributeError as e: 37 | raise ImportError("No module named %s" % obj) from e 38 | return pak 39 | else: 40 | # called with un-dotted string 41 | return __import__(parts[0]) 42 | -------------------------------------------------------------------------------- /traitlets/tests/utils.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from collections.abc import Sequence 5 | from subprocess import PIPE, Popen 6 | from typing import Any 7 | 8 | 9 | def get_output_error_code(cmd: str | Sequence[str]) -> tuple[str, str, Any]: 10 | """Get stdout, stderr, and exit code from running a command""" 11 | p = Popen(cmd, stdout=PIPE, stderr=PIPE) # noqa: S603 12 | out, err = p.communicate() 13 | out_str = out.decode("utf8", "replace") 14 | err_str = err.decode("utf8", "replace") 15 | return out_str, err_str, p.returncode 16 | 17 | 18 | def check_help_output(pkg: str, subcommand: Sequence[str] | None = None) -> tuple[str, str]: 19 | """test that `python -m PKG [subcommand] -h` works""" 20 | cmd = [sys.executable, "-m", pkg] 21 | if subcommand: 22 | cmd.extend(subcommand) 23 | cmd.append("-h") 24 | out, err, rc = get_output_error_code(cmd) 25 | assert rc == 0, err 26 | assert "Traceback" not in err 27 | assert "Options" in out 28 | assert "--help-all" in out 29 | return out, err 30 | 31 | 32 | def check_help_all_output(pkg: str, subcommand: Sequence[str] | None = None) -> tuple[str, str]: 33 | """test that `python -m PKG --help-all` works""" 34 | cmd = [sys.executable, "-m", pkg] 35 | if subcommand: 36 | cmd.extend(subcommand) 37 | cmd.append("--help-all") 38 | out, err, rc = get_output_error_code(cmd) 39 | assert rc == 0, err 40 | assert "Traceback" not in err 41 | assert "Options" in out 42 | assert "Class options" in out 43 | return out, err 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | - Copyright (c) 2001-, IPython Development Team 4 | 5 | All rights reserved. 6 | 7 | Redistribution and use in source and binary forms, with or without 8 | modification, are permitted provided that the following conditions are met: 9 | 10 | 1. Redistributions of source code must retain the above copyright notice, this 11 | list of conditions and the following disclaimer. 12 | 13 | 2. Redistributions in binary form must reproduce the above copyright notice, 14 | this list of conditions and the following disclaimer in the documentation 15 | and/or other materials provided with the distribution. 16 | 17 | 3. Neither the name of the copyright holder nor the names of its 18 | contributors may be used to endorse or promote products derived from 19 | this software without specific prior written permission. 20 | 21 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 22 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 23 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 24 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 25 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 26 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 27 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 28 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 29 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 30 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 31 | -------------------------------------------------------------------------------- /docs/readme-docs.md: -------------------------------------------------------------------------------- 1 | # Documenting traitlets 2 | 3 | [Documentation for `traitlets`](https://traitlets.readthedocs.io/en/latest/) is hosted on ReadTheDocs. 4 | 5 | ## Build documentation locally 6 | 7 | With [`hatch`](https://hatch.pypa.io/), one can build environments, docs, and serve it in one command: 8 | 9 | ``` 10 | pip install hatch 11 | hatch run docs:build 12 | ``` 13 | 14 | #### Build documentation manually 15 | 16 | Otherwise to build docs manually, 17 | 18 | ``` 19 | cd docs 20 | ``` 21 | 22 | Create virtual environment (and install relevant dependencies): 23 | 24 | ``` 25 | virtualenv traitlets_docs -p python3 26 | pip install -r traitlets[docs] 27 | ``` 28 | 29 | The virtualenv should have been automatically activated. If not: 30 | 31 | ``` 32 | source activate 33 | ``` 34 | 35 | ##### Build documentation using: 36 | 37 | - Makefile for Linux and OS X: 38 | 39 | ``` 40 | make html 41 | ``` 42 | 43 | - make.bat for Windows: 44 | 45 | ``` 46 | make.bat html 47 | ``` 48 | 49 | ##### Display the documentation locally 50 | 51 | - Navigate to `build/html/index.html` in your browser. 52 | 53 | - Or alternatively you may run a local server to display 54 | the docs. In Python 3: 55 | 56 | ``` 57 | python -m http.server 8000 58 | ``` 59 | 60 | In your browser, go to `http://localhost:8000`. 61 | 62 | ## Developing Documentation 63 | 64 | [Jupyter documentation guide](https://jupyter.readthedocs.io/en/latest/contrib_docs/index.html) 65 | 66 | ## Helpful files and directories 67 | 68 | - `source/conf.py` - Sphinx build configuration file 69 | - `source` directory - source for documentation 70 | - `source/index.rst` - Main landing page of the Sphinx documentation 71 | -------------------------------------------------------------------------------- /examples/docs/load_config_app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | """A simple example of loading configs and overriding 4 | 5 | Example: 6 | 7 | $ ./examples/docs/load_config_app.py 8 | The school Caltech has a rank of 1. 9 | 10 | $ ./examples/docs/load_config_app.py --name Duke 11 | The school Duke has a rank of 1. 12 | 13 | $ ./examples/docs/load_config_app.py --name Duke --MyApp.MyClass.ranking=12 14 | The school Duke has a rank of 12. 15 | 16 | $ ./examples/docs/load_config_app.py -c "" 17 | The school MIT has a rank of 1. 18 | """ 19 | from __future__ import annotations 20 | 21 | from pathlib import Path 22 | 23 | from traitlets import Int, Unicode 24 | from traitlets.config import Application, Configurable 25 | 26 | 27 | class School(Configurable): 28 | name = Unicode(default_value="MIT").tag(config=True) 29 | ranking = Int(default_value=1).tag(config=True) 30 | 31 | def __str__(self): 32 | return f"The school {self.name} has a rank of {self.ranking}." 33 | 34 | 35 | class MyApp(Application): 36 | classes = [School] 37 | config_file = Unicode(default_value="main_config", help="base name of config file").tag( 38 | config=True 39 | ) 40 | aliases = { 41 | "name": "School.name", 42 | "ranking": "School.ranking", 43 | ("c", "config-file"): "MyApp.config_file", 44 | } 45 | 46 | def initialize(self, argv=None): 47 | super().initialize(argv=argv) 48 | if self.config_file: 49 | self.load_config_file(self.config_file, [Path(__file__).parent / "configs"]) 50 | 51 | def start(self): 52 | print(School(parent=self)) 53 | 54 | 55 | if __name__ == "__main__": 56 | MyApp.launch_instance() 57 | -------------------------------------------------------------------------------- /traitlets/utils/text.py: -------------------------------------------------------------------------------- 1 | """ 2 | Utilities imported from ipython_genutils 3 | """ 4 | from __future__ import annotations 5 | 6 | import re 7 | import textwrap 8 | from textwrap import indent as _indent 9 | 10 | 11 | def indent(val: str) -> str: 12 | return _indent(val, " ") 13 | 14 | 15 | def _dedent(text: str) -> str: 16 | """Equivalent of textwrap.dedent that ignores unindented first line.""" 17 | 18 | if text.startswith("\n"): 19 | # text starts with blank line, don't ignore the first line 20 | return textwrap.dedent(text) 21 | 22 | # split first line 23 | splits = text.split("\n", 1) 24 | if len(splits) == 1: 25 | # only one line 26 | return textwrap.dedent(text) 27 | 28 | first, rest = splits 29 | # dedent everything but the first line 30 | rest = textwrap.dedent(rest) 31 | return "\n".join([first, rest]) 32 | 33 | 34 | def wrap_paragraphs(text: str, ncols: int = 80) -> list[str]: 35 | """Wrap multiple paragraphs to fit a specified width. 36 | 37 | This is equivalent to textwrap.wrap, but with support for multiple 38 | paragraphs, as separated by empty lines. 39 | 40 | Returns 41 | ------- 42 | 43 | list of complete paragraphs, wrapped to fill `ncols` columns. 44 | """ 45 | paragraph_re = re.compile(r"\n(\s*\n)+", re.MULTILINE) 46 | text = _dedent(text).strip() 47 | paragraphs = paragraph_re.split(text)[::2] # every other entry is space 48 | out_ps = [] 49 | indent_re = re.compile(r"\n\s+", re.MULTILINE) 50 | for p in paragraphs: 51 | # presume indentation that survives dedent is meaningful formatting, 52 | # so don't fill unless text is flush. 53 | if indent_re.search(p) is None: 54 | # wrap paragraph 55 | p = textwrap.fill(p, ncols) 56 | out_ps.append(p) 57 | return out_ps 58 | -------------------------------------------------------------------------------- /traitlets/utils/getargspec.py: -------------------------------------------------------------------------------- 1 | """ 2 | getargspec excerpted from: 3 | 4 | sphinx.util.inspect 5 | ~~~~~~~~~~~~~~~~~~~ 6 | Helpers for inspecting Python modules. 7 | :copyright: Copyright 2007-2015 by the Sphinx team, see AUTHORS. 8 | :license: BSD, see LICENSE for details. 9 | """ 10 | from __future__ import annotations 11 | 12 | import inspect 13 | from functools import partial 14 | from typing import Any 15 | 16 | # Unmodified from sphinx below this line 17 | 18 | 19 | def getargspec(func: Any) -> inspect.FullArgSpec: 20 | """Like inspect.getargspec but supports functools.partial as well.""" 21 | if inspect.ismethod(func): 22 | func = func.__func__ 23 | if type(func) is partial: 24 | orig_func = func.func 25 | argspec = getargspec(orig_func) 26 | args = list(argspec[0]) 27 | defaults = list(argspec[3] or ()) 28 | kwoargs = list(argspec[4]) 29 | kwodefs = dict(argspec[5] or {}) 30 | if func.args: 31 | args = args[len(func.args) :] 32 | for arg in func.keywords or (): 33 | try: 34 | i = args.index(arg) - len(args) 35 | del args[i] 36 | try: 37 | del defaults[i] 38 | except IndexError: 39 | pass 40 | except ValueError: # must be a kwonly arg 41 | i = kwoargs.index(arg) 42 | del kwoargs[i] 43 | del kwodefs[arg] 44 | return inspect.FullArgSpec( 45 | args, argspec[1], argspec[2], tuple(defaults), kwoargs, kwodefs, argspec[6] 46 | ) 47 | while hasattr(func, "__wrapped__"): 48 | func = func.__wrapped__ 49 | if not inspect.isfunction(func): 50 | raise TypeError("%r is not a Python function" % func) 51 | return inspect.getfullargspec(func) 52 | -------------------------------------------------------------------------------- /.github/workflows/prep-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 1: Prep Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | version_spec: 6 | description: "New Version Specifier" 7 | default: "next" 8 | required: false 9 | branch: 10 | description: "The branch to target" 11 | required: false 12 | post_version_spec: 13 | description: "Post Version Specifier" 14 | required: false 15 | silent: 16 | description: "Set a placeholder in the changelog and don't publish the release." 17 | required: false 18 | type: boolean 19 | since: 20 | description: "Use PRs with activity since this date or git reference" 21 | required: false 22 | since_last_stable: 23 | description: "Use PRs with activity since the last stable git tag" 24 | required: false 25 | type: boolean 26 | jobs: 27 | prep_release: 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: write 31 | steps: 32 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 33 | 34 | - name: Prep Release 35 | id: prep-release 36 | uses: jupyter-server/jupyter_releaser/.github/actions/prep-release@v2 37 | with: 38 | token: ${{ secrets.GITHUB_TOKEN }} 39 | version_spec: ${{ github.event.inputs.version_spec }} 40 | silent: ${{ github.event.inputs.silent }} 41 | post_version_spec: ${{ github.event.inputs.post_version_spec }} 42 | target: ${{ github.event.inputs.target }} 43 | branch: ${{ github.event.inputs.branch }} 44 | since: ${{ github.event.inputs.since }} 45 | since_last_stable: ${{ github.event.inputs.since_last_stable }} 46 | 47 | - name: "** Next Step **" 48 | run: | 49 | echo "Optional): Review Draft Release: ${{ steps.prep-release.outputs.release_url }}" 50 | -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: "Step 2: Publish Release" 2 | on: 3 | workflow_dispatch: 4 | inputs: 5 | branch: 6 | description: "The target branch" 7 | required: false 8 | release_url: 9 | description: "The URL of the draft GitHub release" 10 | required: false 11 | steps_to_skip: 12 | description: "Comma separated list of steps to skip" 13 | required: false 14 | 15 | jobs: 16 | publish_release: 17 | runs-on: ubuntu-latest 18 | environment: release 19 | permissions: 20 | id-token: write 21 | steps: 22 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 23 | 24 | - uses: actions/create-github-app-token@v1 25 | id: app-token 26 | with: 27 | app-id: ${{ vars.APP_ID }} 28 | private-key: ${{ secrets.APP_PRIVATE_KEY }} 29 | 30 | - name: Populate Release 31 | id: populate-release 32 | uses: jupyter-server/jupyter_releaser/.github/actions/populate-release@v2 33 | with: 34 | token: ${{ steps.app-token.outputs.token }} 35 | branch: ${{ github.event.inputs.branch }} 36 | release_url: ${{ github.event.inputs.release_url }} 37 | steps_to_skip: ${{ github.event.inputs.steps_to_skip }} 38 | 39 | - name: Finalize Release 40 | id: finalize-release 41 | uses: jupyter-server/jupyter_releaser/.github/actions/finalize-release@v2 42 | with: 43 | token: ${{ steps.app-token.outputs.token }} 44 | release_url: ${{ steps.populate-release.outputs.release_url }} 45 | 46 | - name: "** Next Step **" 47 | if: ${{ success() }} 48 | run: | 49 | echo "Verify the final release" 50 | echo ${{ steps.finalize-release.outputs.release_url }} 51 | 52 | - name: "** Failure Message **" 53 | if: ${{ failure() }} 54 | run: | 55 | echo "Failed to Publish the Draft Release Url:" 56 | echo ${{ steps.populate-release.outputs.release_url }} 57 | -------------------------------------------------------------------------------- /traitlets/utils/warnings.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import os 5 | import typing as t 6 | import warnings 7 | 8 | 9 | def warn(msg: str, category: t.Any, *, stacklevel: int, source: t.Any = None) -> None: 10 | """Like warnings.warn(), but category and stacklevel are required. 11 | 12 | You pretty much never want the default stacklevel of 1, so this helps 13 | encourage setting it explicitly.""" 14 | warnings.warn(msg, category=category, stacklevel=stacklevel, source=source) 15 | 16 | 17 | def deprecated_method(method: t.Any, cls: t.Any, method_name: str, msg: str) -> None: 18 | """Show deprecation warning about a magic method definition. 19 | 20 | Uses warn_explicit to bind warning to method definition instead of triggering code, 21 | which isn't relevant. 22 | """ 23 | warn_msg = f"{cls.__name__}.{method_name} is deprecated in traitlets 4.1: {msg}" 24 | 25 | for parent in inspect.getmro(cls): 26 | if method_name in parent.__dict__: 27 | cls = parent 28 | break 29 | # limit deprecation messages to once per package 30 | package_name = cls.__module__.split(".", 1)[0] 31 | key = (package_name, msg) 32 | if not should_warn(key): 33 | return 34 | try: 35 | fname = inspect.getsourcefile(method) or "" 36 | lineno = inspect.getsourcelines(method)[1] or 0 37 | except (OSError, TypeError) as e: 38 | # Failed to inspect for some reason 39 | warn( 40 | warn_msg + ("\n(inspection failed) %s" % e), 41 | DeprecationWarning, 42 | stacklevel=2, 43 | ) 44 | else: 45 | warnings.warn_explicit(warn_msg, DeprecationWarning, fname, lineno) 46 | 47 | 48 | _deprecations_shown = set() 49 | 50 | 51 | def should_warn(key: t.Any) -> bool: 52 | """Add our own checks for too many deprecation warnings. 53 | 54 | Limit to once per package. 55 | """ 56 | env_flag = os.environ.get("TRAITLETS_ALL_DEPRECATIONS") 57 | if env_flag and env_flag != "0": 58 | return True 59 | 60 | if key not in _deprecations_shown: 61 | _deprecations_shown.add(key) 62 | return True 63 | else: 64 | return False 65 | -------------------------------------------------------------------------------- /traitlets/tests/test_traitlets.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import Any 4 | from unittest import TestCase 5 | 6 | from traitlets import TraitError 7 | 8 | 9 | class TraitTestBase(TestCase): 10 | """A best testing class for basic trait types.""" 11 | 12 | def assign(self, value: Any) -> None: 13 | self.obj.value = value # type:ignore[attr-defined] 14 | 15 | def coerce(self, value: Any) -> Any: 16 | return value 17 | 18 | def test_good_values(self) -> None: 19 | if hasattr(self, "_good_values"): 20 | for value in self._good_values: 21 | self.assign(value) 22 | self.assertEqual(self.obj.value, self.coerce(value)) # type:ignore[attr-defined] 23 | 24 | def test_bad_values(self) -> None: 25 | if hasattr(self, "_bad_values"): 26 | for value in self._bad_values: 27 | try: 28 | self.assertRaises(TraitError, self.assign, value) 29 | except AssertionError: 30 | raise AssertionError(value) from None 31 | 32 | def test_default_value(self) -> None: 33 | if hasattr(self, "_default_value"): 34 | self.assertEqual(self._default_value, self.obj.value) # type:ignore[attr-defined] 35 | 36 | def test_allow_none(self) -> None: 37 | if ( 38 | hasattr(self, "_bad_values") 39 | and hasattr(self, "_good_values") 40 | and None in self._bad_values 41 | ): 42 | trait = self.obj.traits()["value"] # type:ignore[attr-defined] 43 | try: 44 | trait.allow_none = True 45 | self._bad_values.remove(None) 46 | # skip coerce. Allow None casts None to None. 47 | self.assign(None) 48 | self.assertEqual(self.obj.value, None) # type:ignore[attr-defined] 49 | self.test_good_values() 50 | self.test_bad_values() 51 | finally: 52 | # tear down 53 | trait.allow_none = False 54 | self._bad_values.append(None) 55 | 56 | def tearDown(self) -> None: 57 | # restore default value after tests, if set 58 | if hasattr(self, "_default_value"): 59 | self.obj.value = self._default_value # type:ignore[attr-defined] 60 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | ci: 2 | autoupdate_schedule: monthly 3 | autoupdate_commit_msg: "chore: update pre-commit hooks" 4 | 5 | repos: 6 | - repo: https://github.com/pre-commit/pre-commit-hooks 7 | rev: v4.5.0 8 | hooks: 9 | - id: check-case-conflict 10 | - id: check-ast 11 | - id: check-docstring-first 12 | - id: check-executables-have-shebangs 13 | - id: check-added-large-files 14 | - id: check-case-conflict 15 | - id: check-merge-conflict 16 | - id: check-json 17 | - id: check-toml 18 | - id: check-yaml 19 | - id: debug-statements 20 | - id: end-of-file-fixer 21 | - id: trailing-whitespace 22 | 23 | - repo: https://github.com/python-jsonschema/check-jsonschema 24 | rev: 0.27.4 25 | hooks: 26 | - id: check-github-workflows 27 | 28 | - repo: https://github.com/executablebooks/mdformat 29 | rev: 0.7.17 30 | hooks: 31 | - id: mdformat 32 | additional_dependencies: 33 | [mdformat-gfm, mdformat-frontmatter, mdformat-footnote] 34 | 35 | - repo: https://github.com/pre-commit/mirrors-prettier 36 | rev: "v4.0.0-alpha.8" 37 | hooks: 38 | - id: prettier 39 | types_or: [yaml, html, json] 40 | 41 | - repo: https://github.com/adamchainz/blacken-docs 42 | rev: "1.16.0" 43 | hooks: 44 | - id: blacken-docs 45 | additional_dependencies: [black==23.7.0] 46 | 47 | - repo: https://github.com/codespell-project/codespell 48 | rev: "v2.2.6" 49 | hooks: 50 | - id: codespell 51 | args: ["-L", "sur,nd"] 52 | 53 | - repo: https://github.com/pre-commit/mirrors-mypy 54 | rev: "v1.8.0" 55 | hooks: 56 | - id: mypy 57 | files: "^traitlets" 58 | stages: [manual] 59 | args: ["--install-types", "--non-interactive"] 60 | additional_dependencies: ["argcomplete>=3.1"] 61 | 62 | - repo: https://github.com/pre-commit/pygrep-hooks 63 | rev: "v1.10.0" 64 | hooks: 65 | - id: rst-backticks 66 | - id: rst-directive-colons 67 | - id: rst-inline-touching-normal 68 | 69 | - repo: https://github.com/astral-sh/ruff-pre-commit 70 | rev: v0.2.0 71 | hooks: 72 | - id: ruff 73 | types_or: [python, jupyter] 74 | args: ["--fix", "--show-fixes"] 75 | - id: ruff-format 76 | types_or: [python, jupyter] 77 | 78 | - repo: https://github.com/scientific-python/cookie 79 | rev: "2024.01.24" 80 | hooks: 81 | - id: sp-repo-review 82 | additional_dependencies: ["repo-review[cli]"] 83 | -------------------------------------------------------------------------------- /examples/subcommands_app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | """A simple example to demonstrate subcommands with traitlets.Application 4 | 5 | Example: 6 | 7 | $ examples/subcommands_app.py foo --print-name alice 8 | foo 9 | hello alice 10 | 11 | $ examples/subcommands_app.py bar --print-name bob 12 | bar 13 | hello bob 14 | """ 15 | from __future__ import annotations 16 | 17 | from traitlets import Enum, Unicode 18 | from traitlets.config.application import Application 19 | from traitlets.config.configurable import Configurable 20 | 21 | 22 | class PrintHello(Configurable): 23 | greet_name = Unicode("world").tag(config=True) 24 | greeting = Enum(values=["hello", "hi", "bye"], default_value="hello").tag(config=True) 25 | 26 | def run(self): 27 | print(f"{self.greeting} {self.greet_name}") 28 | 29 | 30 | class FooApp(Application): 31 | name = Unicode("foo") 32 | classes = [PrintHello] 33 | aliases = { 34 | "print-name": "PrintHello.greet_name", 35 | } 36 | 37 | config_file = Unicode("", help="Load this config file").tag(config=True) 38 | 39 | def start(self): 40 | print(self.name) 41 | PrintHello(parent=self).run() 42 | 43 | 44 | class BarApp(Application): 45 | name = Unicode("bar") 46 | classes = [PrintHello] 47 | aliases = { 48 | "print-name": "PrintHello.greet_name", 49 | } 50 | 51 | config_file = Unicode("", help="Load this config file").tag(config=True) 52 | 53 | def start(self): 54 | print(self.name) 55 | PrintHello(parent=self).run() 56 | 57 | @classmethod 58 | def get_subapp(cls, main_app: Application) -> Application: 59 | main_app.clear_instance() 60 | return cls.instance(parent=main_app) # type: ignore[no-any-return] 61 | 62 | 63 | class MainApp(Application): 64 | name = Unicode("subcommand-example-app") 65 | description = Unicode("demonstrates app with subcommands") 66 | subcommands = { 67 | # Subcommands should be a dictionary mapping from the subcommand name 68 | # to one of the following: 69 | # 1. The Application class to be instantiated e.g. FooApp 70 | # 2. A string e.g. "traitlets.examples.subcommands_app.FooApp" 71 | # which will be lazily evaluated 72 | # 3. A callable which takes this Application and returns an instance 73 | # (not class) of the subcommmand Application 74 | "foo": (FooApp, "run foo"), 75 | "bar": (BarApp.get_subapp, "run bar"), 76 | } 77 | 78 | 79 | if __name__ == "__main__": 80 | MainApp.launch_instance() 81 | -------------------------------------------------------------------------------- /.github/workflows/downstream.yml: -------------------------------------------------------------------------------- 1 | name: Test downstream projects 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | pull_request: 7 | 8 | concurrency: 9 | group: downstream-${{ github.ref }} 10 | cancel-in-progress: true 11 | 12 | jobs: 13 | ipython: 14 | runs-on: ubuntu-latest 15 | timeout-minutes: 10 16 | steps: 17 | - uses: actions/checkout@v4 18 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 19 | - uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 20 | with: 21 | package_name: ipython 22 | package_spec: -e ".[test]" 23 | 24 | nbconvert: 25 | runs-on: ubuntu-latest 26 | timeout-minutes: 10 27 | steps: 28 | - uses: actions/checkout@v4 29 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 30 | - uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 31 | with: 32 | package_name: nbconvert 33 | package_spec: -e ".[test]" 34 | 35 | jupyter_server: 36 | runs-on: ubuntu-latest 37 | timeout-minutes: 10 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 41 | - uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 42 | with: 43 | package_name: jupyter_server 44 | 45 | ipywidgets: 46 | runs-on: ubuntu-latest 47 | timeout-minutes: 10 48 | steps: 49 | - uses: actions/checkout@v4 50 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 51 | - uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 52 | with: 53 | package_name: ipywidgets 54 | 55 | notebook: 56 | runs-on: ubuntu-latest 57 | timeout-minutes: 10 58 | steps: 59 | - uses: actions/checkout@v4 60 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 61 | - uses: jupyterlab/maintainer-tools/.github/actions/downstream-test@v1 62 | with: 63 | package_name: notebook 64 | test_command: pytest -vv --ignore-glob=notebook/tests/selenium/* --ignore-glob=notebook/nbconvert/tests/* --ignore-glob=notebook/services/nbconvert/tests/* 65 | 66 | downstream_check: # This job does nothing and is only used for the branch protection 67 | if: always() 68 | needs: 69 | - notebook 70 | - ipywidgets 71 | - jupyter_server 72 | - nbconvert 73 | - ipython 74 | runs-on: ubuntu-latest 75 | steps: 76 | - name: Decide whether the needed jobs succeeded or failed 77 | uses: re-actors/alls-green@release/v1 78 | with: 79 | jobs: ${{ toJSON(needs) }} 80 | -------------------------------------------------------------------------------- /docs/source/trait_types.rst: -------------------------------------------------------------------------------- 1 | Trait Types 2 | =========== 3 | 4 | .. module:: traitlets 5 | 6 | .. class:: TraitType 7 | 8 | The base class for all trait types. 9 | 10 | .. automethod:: __init__ 11 | 12 | .. automethod:: from_string 13 | 14 | Numbers 15 | ------- 16 | 17 | .. autoclass:: Integer 18 | 19 | An integer trait. On Python 2, this automatically uses the ``int`` or 20 | ``long`` types as necessary. 21 | 22 | .. class:: Int 23 | .. class:: Long 24 | 25 | On Python 2, these are traitlets for values where the ``int`` and ``long`` 26 | types are not interchangeable. On Python 3, they are both aliases for 27 | :class:`Integer`. 28 | 29 | In almost all situations, you should use :class:`Integer` instead of these. 30 | 31 | .. autoclass:: Float 32 | 33 | .. autoclass:: Complex 34 | 35 | .. class:: CInt 36 | CLong 37 | CFloat 38 | CComplex 39 | 40 | Casting variants of the above. When a value is assigned to the attribute, 41 | these will attempt to convert it by calling e.g. ``value = int(value)``. 42 | 43 | Strings 44 | ------- 45 | 46 | .. autoclass:: Unicode 47 | 48 | .. autoclass:: Bytes 49 | 50 | .. class:: CUnicode 51 | CBytes 52 | 53 | Casting variants. When a value is assigned to the attribute, these will 54 | attempt to convert it to their type. They will not automatically encode/decode 55 | between unicode and bytes, however. 56 | 57 | .. autoclass:: ObjectName 58 | 59 | .. autoclass:: DottedObjectName 60 | 61 | Containers 62 | ---------- 63 | 64 | .. autoclass:: List 65 | :members: __init__, from_string_list, item_from_string 66 | 67 | .. autoclass:: Set 68 | :members: __init__ 69 | 70 | .. autoclass:: Tuple 71 | :members: __init__ 72 | 73 | .. autoclass:: Dict 74 | :members: __init__, from_string_list, item_from_string 75 | 76 | Classes and instances 77 | --------------------- 78 | 79 | .. autoclass:: Instance 80 | :members: __init__ 81 | 82 | .. autoclass:: Type 83 | :members: __init__ 84 | 85 | .. autoclass:: This 86 | 87 | .. autoclass:: ForwardDeclaredInstance 88 | 89 | .. autoclass:: ForwardDeclaredType 90 | 91 | 92 | Miscellaneous 93 | ------------- 94 | 95 | .. autoclass:: Bool 96 | 97 | .. class:: CBool 98 | 99 | Casting variant. When a value is assigned to the attribute, this will 100 | attempt to convert it by calling ``value = bool(value)``. 101 | 102 | .. autoclass:: Enum 103 | 104 | .. autoclass:: CaselessStrEnum 105 | 106 | .. autoclass:: UseEnum 107 | 108 | .. autoclass:: TCPAddress 109 | 110 | .. autoclass:: CRegExp 111 | 112 | .. autoclass:: Union 113 | :members: __init__ 114 | 115 | .. autoclass:: Callable 116 | 117 | .. autoclass:: Any 118 | -------------------------------------------------------------------------------- /traitlets/config/manager.py: -------------------------------------------------------------------------------- 1 | """Manager to read and modify config data in JSON files. 2 | """ 3 | # Copyright (c) IPython Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import errno 8 | import json 9 | import os 10 | from typing import Any 11 | 12 | from traitlets.config import LoggingConfigurable 13 | from traitlets.traitlets import Unicode 14 | 15 | 16 | def recursive_update(target: dict[Any, Any], new: dict[Any, Any]) -> None: 17 | """Recursively update one dictionary using another. 18 | 19 | None values will delete their keys. 20 | """ 21 | for k, v in new.items(): 22 | if isinstance(v, dict): 23 | if k not in target: 24 | target[k] = {} 25 | recursive_update(target[k], v) 26 | if not target[k]: 27 | # Prune empty subdicts 28 | del target[k] 29 | 30 | elif v is None: 31 | target.pop(k, None) 32 | 33 | else: 34 | target[k] = v 35 | 36 | 37 | class BaseJSONConfigManager(LoggingConfigurable): 38 | """General JSON config manager 39 | 40 | Deals with persisting/storing config in a json file 41 | """ 42 | 43 | config_dir = Unicode(".") 44 | 45 | def ensure_config_dir_exists(self) -> None: 46 | try: 47 | os.makedirs(self.config_dir, 0o755) 48 | except OSError as e: 49 | if e.errno != errno.EEXIST: 50 | raise 51 | 52 | def file_name(self, section_name: str) -> str: 53 | return os.path.join(self.config_dir, section_name + ".json") 54 | 55 | def get(self, section_name: str) -> Any: 56 | """Retrieve the config data for the specified section. 57 | 58 | Returns the data as a dictionary, or an empty dictionary if the file 59 | doesn't exist. 60 | """ 61 | filename = self.file_name(section_name) 62 | if os.path.isfile(filename): 63 | with open(filename, encoding="utf-8") as f: 64 | return json.load(f) 65 | else: 66 | return {} 67 | 68 | def set(self, section_name: str, data: Any) -> None: 69 | """Store the given config data.""" 70 | filename = self.file_name(section_name) 71 | self.ensure_config_dir_exists() 72 | 73 | with open(filename, "w", encoding="utf-8") as f: 74 | json.dump(data, f, indent=2) 75 | 76 | def update(self, section_name: str, new_data: Any) -> Any: 77 | """Modify the config section by recursively updating it with new_data. 78 | 79 | Returns the modified config data as a dictionary. 80 | """ 81 | data = self.get(section_name) 82 | recursive_update(data, new_data) 83 | self.set(section_name, data) 84 | return data 85 | -------------------------------------------------------------------------------- /tests/test_traitlets_docstring.py: -------------------------------------------------------------------------------- 1 | """Tests for traitlets.traitlets.""" 2 | 3 | # Copyright (c) IPython Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | # 6 | from __future__ import annotations 7 | 8 | from traitlets import Dict, Instance, Integer, Unicode, Union 9 | from traitlets.config import Configurable 10 | 11 | 12 | def test_handle_docstring(): 13 | class SampleConfigurable(Configurable): 14 | pass 15 | 16 | class TraitTypesSampleConfigurable(Configurable): 17 | """TraitTypesSampleConfigurable docstring""" 18 | 19 | trait_integer = Integer( 20 | help="""trait_integer help text""", 21 | config=True, 22 | ) 23 | trait_integer_nohelp = Integer( 24 | config=True, 25 | ) 26 | trait_integer_noconfig = Integer( 27 | help="""trait_integer_noconfig help text""", 28 | ) 29 | 30 | trait_unicode = Unicode( 31 | help="""trait_unicode help text""", 32 | config=True, 33 | ) 34 | trait_unicode_nohelp = Unicode( 35 | config=True, 36 | ) 37 | trait_unicode_noconfig = Unicode( 38 | help="""trait_unicode_noconfig help text""", 39 | ) 40 | 41 | trait_dict = Dict( 42 | help="""trait_dict help text""", 43 | config=True, 44 | ) 45 | trait_dict_nohelp = Dict( 46 | config=True, 47 | ) 48 | trait_dict_noconfig = Dict( 49 | help="""trait_dict_noconfig help text""", 50 | ) 51 | 52 | trait_instance = Instance( 53 | klass=SampleConfigurable, 54 | help="""trait_instance help text""", 55 | config=True, 56 | ) 57 | trait_instance_nohelp = Instance( 58 | klass=SampleConfigurable, 59 | config=True, 60 | ) 61 | trait_instance_noconfig = Instance( 62 | klass=SampleConfigurable, 63 | help="""trait_instance_noconfig help text""", 64 | ) 65 | 66 | trait_union = Union( 67 | [Integer(), Unicode()], 68 | help="""trait_union help text""", 69 | config=True, 70 | ) 71 | trait_union_nohelp = Union( 72 | [Integer(), Unicode()], 73 | config=True, 74 | ) 75 | trait_union_noconfig = Union( 76 | [Integer(), Unicode()], 77 | help="""trait_union_noconfig help text""", 78 | ) 79 | 80 | base_names = SampleConfigurable().trait_names() 81 | for name in TraitTypesSampleConfigurable().trait_names(): 82 | if name in base_names: 83 | continue 84 | doc = getattr(TraitTypesSampleConfigurable, name).__doc__ 85 | if "nohelp" not in name: 86 | assert doc == f"{name} help text" 87 | -------------------------------------------------------------------------------- /traitlets/utils/decorators.py: -------------------------------------------------------------------------------- 1 | """Useful decorators for Traitlets users.""" 2 | from __future__ import annotations 3 | 4 | import copy 5 | from inspect import Parameter, Signature, signature 6 | from typing import Any, TypeVar 7 | 8 | from ..traitlets import HasTraits, Undefined 9 | 10 | 11 | def _get_default(value: Any) -> Any: 12 | """Get default argument value, given the trait default value.""" 13 | return Parameter.empty if value == Undefined else value 14 | 15 | 16 | T = TypeVar("T", bound=HasTraits) 17 | 18 | 19 | def signature_has_traits(cls: type[T]) -> type[T]: 20 | """Return a decorated class with a constructor signature that contain Trait names as kwargs.""" 21 | traits = [ 22 | (name, _get_default(value.default_value)) 23 | for name, value in cls.class_traits().items() 24 | if not name.startswith("_") 25 | ] 26 | 27 | # Taking the __init__ signature, as the cls signature is not initialized yet 28 | old_signature = signature(cls.__init__) 29 | old_parameter_names = list(old_signature.parameters) 30 | 31 | old_positional_parameters = [] 32 | old_var_positional_parameter = None # This won't be None if the old signature contains *args 33 | old_keyword_only_parameters = [] 34 | old_var_keyword_parameter = None # This won't be None if the old signature contains **kwargs 35 | 36 | for parameter_name in old_signature.parameters: 37 | # Copy the parameter 38 | parameter = copy.copy(old_signature.parameters[parameter_name]) 39 | 40 | if ( 41 | parameter.kind is Parameter.POSITIONAL_ONLY 42 | or parameter.kind is Parameter.POSITIONAL_OR_KEYWORD 43 | ): 44 | old_positional_parameters.append(parameter) 45 | 46 | elif parameter.kind is Parameter.VAR_POSITIONAL: 47 | old_var_positional_parameter = parameter 48 | 49 | elif parameter.kind is Parameter.KEYWORD_ONLY: 50 | old_keyword_only_parameters.append(parameter) 51 | 52 | elif parameter.kind is Parameter.VAR_KEYWORD: 53 | old_var_keyword_parameter = parameter 54 | 55 | # Unfortunately, if the old signature does not contain **kwargs, we can't do anything, 56 | # because it can't accept traits as keyword arguments 57 | if old_var_keyword_parameter is None: 58 | raise RuntimeError( 59 | f"The {cls} constructor does not take **kwargs, which means that the signature can not be expanded with trait names" 60 | ) 61 | 62 | new_parameters = [] 63 | 64 | # Append the old positional parameters (except `self` which is the first parameter) 65 | new_parameters += old_positional_parameters[1:] 66 | 67 | # Append *args if the old signature had it 68 | if old_var_positional_parameter is not None: 69 | new_parameters.append(old_var_positional_parameter) 70 | 71 | # Append the old keyword only parameters 72 | new_parameters += old_keyword_only_parameters 73 | 74 | # Append trait names as keyword only parameters in the signature 75 | new_parameters += [ 76 | Parameter(name, kind=Parameter.KEYWORD_ONLY, default=default) 77 | for name, default in traits 78 | if name not in old_parameter_names 79 | ] 80 | 81 | # Append **kwargs 82 | new_parameters.append(old_var_keyword_parameter) 83 | 84 | cls.__signature__ = Signature(new_parameters) # type:ignore[attr-defined] 85 | 86 | return cls 87 | -------------------------------------------------------------------------------- /traitlets/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | import pathlib 5 | from collections.abc import Sequence 6 | 7 | 8 | # vestigal things from IPython_genutils. 9 | def cast_unicode(s: str | bytes, encoding: str = "utf-8") -> str: 10 | if isinstance(s, bytes): 11 | return s.decode(encoding, "replace") 12 | return s 13 | 14 | 15 | def filefind(filename: str, path_dirs: Sequence[str] | None = None) -> str: 16 | """Find a file by looking through a sequence of paths. 17 | 18 | This iterates through a sequence of paths looking for a file and returns 19 | the full, absolute path of the first occurrence of the file. If no set of 20 | path dirs is given, the filename is tested as is, after running through 21 | :func:`expandvars` and :func:`expanduser`. Thus a simple call:: 22 | 23 | filefind('myfile.txt') 24 | 25 | will find the file in the current working dir, but:: 26 | 27 | filefind('~/myfile.txt') 28 | 29 | Will find the file in the users home directory. This function does not 30 | automatically try any paths, such as the cwd or the user's home directory. 31 | 32 | Parameters 33 | ---------- 34 | filename : str 35 | The filename to look for. 36 | path_dirs : str, None or sequence of str 37 | The sequence of paths to look for the file in. If None, the filename 38 | need to be absolute or be in the cwd. If a string, the string is 39 | put into a sequence and the searched. If a sequence, walk through 40 | each element and join with ``filename``, calling :func:`expandvars` 41 | and :func:`expanduser` before testing for existence. 42 | 43 | Returns 44 | ------- 45 | Raises :exc:`IOError` or returns absolute path to file. 46 | """ 47 | 48 | # If paths are quoted, abspath gets confused, strip them... 49 | filename = filename.strip('"').strip("'") 50 | # If the input is an absolute path, just check it exists 51 | if os.path.isabs(filename) and os.path.isfile(filename): 52 | return filename 53 | 54 | if path_dirs is None: 55 | path_dirs = ("",) 56 | elif isinstance(path_dirs, str): 57 | path_dirs = (path_dirs,) 58 | elif isinstance(path_dirs, pathlib.Path): 59 | path_dirs = (str(path_dirs),) 60 | 61 | for path in path_dirs: 62 | if path == ".": 63 | path = os.getcwd() 64 | testname = expand_path(os.path.join(path, filename)) 65 | if os.path.isfile(testname): 66 | return os.path.abspath(testname) 67 | 68 | raise OSError(f"File {filename!r} does not exist in any of the search paths: {path_dirs!r}") 69 | 70 | 71 | def expand_path(s: str) -> str: 72 | """Expand $VARS and ~names in a string, like a shell 73 | 74 | :Examples: 75 | 76 | In [2]: os.environ['FOO']='test' 77 | 78 | In [3]: expand_path('variable FOO is $FOO') 79 | Out[3]: 'variable FOO is test' 80 | """ 81 | # This is a pretty subtle hack. When expand user is given a UNC path 82 | # on Windows (\\server\share$\%username%), os.path.expandvars, removes 83 | # the $ to get (\\server\share\%username%). I think it considered $ 84 | # alone an empty var. But, we need the $ to remains there (it indicates 85 | # a hidden share). 86 | if os.name == "nt": 87 | s = s.replace("$\\", "IPYTHON_TEMP") 88 | s = os.path.expandvars(os.path.expanduser(s)) 89 | if os.name == "nt": 90 | s = s.replace("IPYTHON_TEMP", "$\\") 91 | return s 92 | -------------------------------------------------------------------------------- /docs/source/api.rst: -------------------------------------------------------------------------------- 1 | Traitlets API reference 2 | ======================= 3 | 4 | .. currentmodule:: traitlets 5 | 6 | Any class with trait attributes must inherit from :class:`HasTraits`. 7 | 8 | .. autoclass:: HasTraits 9 | 10 | .. automethod:: has_trait 11 | 12 | .. automethod:: trait_has_value 13 | 14 | .. automethod:: trait_names 15 | 16 | .. automethod:: class_trait_names 17 | 18 | .. automethod:: traits 19 | 20 | .. automethod:: class_traits 21 | 22 | .. automethod:: trait_metadata 23 | 24 | .. automethod:: add_traits 25 | 26 | You then declare the trait attributes on the class like this:: 27 | 28 | from traitlets import HasTraits, Int, Unicode 29 | 30 | class Requester(HasTraits): 31 | url = Unicode() 32 | timeout = Int(30) # 30 will be the default value 33 | 34 | For the available trait types and the arguments you can give them, see 35 | :doc:`trait_types`. 36 | 37 | 38 | Dynamic default values 39 | ---------------------- 40 | 41 | .. autofunction:: default 42 | 43 | To calculate a default value dynamically, decorate a method of your class with 44 | ``@default({traitname})``. This method will be called on the instance, and should 45 | return the default value. For example:: 46 | 47 | import getpass 48 | 49 | class Identity(HasTraits): 50 | username = Unicode() 51 | 52 | @default('username') 53 | def _username_default(self): 54 | return getpass.getuser() 55 | 56 | 57 | Callbacks when trait attributes change 58 | -------------------------------------- 59 | 60 | .. autofunction:: observe 61 | 62 | To do something when a trait attribute is changed, decorate a method with :func:`traitlets.observe`. 63 | The method will be called with a single argument, a dictionary of the form:: 64 | 65 | { 66 | 'owner': object, # The HasTraits instance 67 | 'new': 6, # The new value 68 | 'old': 5, # The old value 69 | 'name': "foo", # The name of the changed trait 70 | 'type': 'change', # The event type of the notification, usually 'change' 71 | } 72 | 73 | For example:: 74 | 75 | from traitlets import HasTraits, Integer, observe 76 | 77 | class TraitletsExample(HasTraits): 78 | num = Integer(5, help="a number").tag(config=True) 79 | 80 | @observe('num') 81 | def _num_changed(self, change): 82 | print("{name} changed from {old} to {new}".format(**change)) 83 | 84 | 85 | .. versionchanged:: 4.1 86 | 87 | The ``_{trait}_changed`` magic method-name approach is deprecated. 88 | 89 | You can also add callbacks to a trait dynamically: 90 | 91 | .. automethod:: HasTraits.observe 92 | 93 | .. note:: 94 | 95 | If a trait attribute with a dynamic default value has another value set 96 | before it is used, the default will not be calculated. 97 | Any callbacks on that trait will will fire, and *old_value* will be ``None``. 98 | 99 | Validating proposed changes 100 | --------------------------- 101 | 102 | .. autofunction:: validate 103 | 104 | Validator methods can be used to enforce certain aspects of a property. 105 | These are called on proposed changes, 106 | and can raise a TraitError if the change should be rejected, 107 | or coerce the value if it should be accepted with some modification. 108 | This can be useful for things such as ensuring a path string is always absolute, 109 | or check if it points to an existing directory. 110 | 111 | For example:: 112 | 113 | from traitlets import HasTraits, Unicode, validate, TraitError 114 | 115 | class TraitletsExample(HasTraits): 116 | path = Unicode('', help="a path") 117 | 118 | @validate('path') 119 | def _check_prime(self, proposal): 120 | path = proposal['value'] 121 | if not path.endswith('/'): 122 | # ensure path always has trailing / 123 | path = path + '/' 124 | if not os.path.exists(path): 125 | raise TraitError("path %r does not exist" % path) 126 | return path 127 | -------------------------------------------------------------------------------- /tests/_warnings.py: -------------------------------------------------------------------------------- 1 | # From scikit-image: https://github.com/scikit-image/scikit-image/blob/c2f8c4ab123ebe5f7b827bc495625a32bb225c10/skimage/_shared/_warnings.py 2 | # Licensed under modified BSD license 3 | from __future__ import annotations 4 | 5 | __all__ = ["all_warnings", "expected_warnings"] 6 | 7 | import inspect 8 | import os 9 | import re 10 | import sys 11 | import warnings 12 | from contextlib import contextmanager 13 | from unittest import mock 14 | 15 | 16 | @contextmanager 17 | def all_warnings(): 18 | """ 19 | Context for use in testing to ensure that all warnings are raised. 20 | Examples 21 | -------- 22 | >>> import warnings 23 | >>> def foo(): 24 | ... warnings.warn(RuntimeWarning("bar")) 25 | 26 | We raise the warning once, while the warning filter is set to "once". 27 | Hereafter, the warning is invisible, even with custom filters: 28 | >>> with warnings.catch_warnings(): 29 | ... warnings.simplefilter('once') 30 | ... foo() 31 | 32 | We can now run ``foo()`` without a warning being raised: 33 | >>> from numpy.testing import assert_warns # doctest: +SKIP 34 | >>> foo() # doctest: +SKIP 35 | 36 | To catch the warning, we call in the help of ``all_warnings``: 37 | >>> with all_warnings(): # doctest: +SKIP 38 | ... assert_warns(RuntimeWarning, foo) 39 | """ 40 | 41 | # Whenever a warning is triggered, Python adds a __warningregistry__ 42 | # member to the *calling* module. The exercise here is to find 43 | # and eradicate all those breadcrumbs that were left lying around. 44 | # 45 | # We proceed by first searching all parent calling frames and explicitly 46 | # clearing their warning registries (necessary for the doctests above to 47 | # pass). Then, we search for all submodules of skimage and clear theirs 48 | # as well (necessary for the skimage test suite to pass). 49 | 50 | frame = inspect.currentframe() 51 | if frame: 52 | for f in inspect.getouterframes(frame): 53 | f[0].f_locals["__warningregistry__"] = {} 54 | del frame 55 | 56 | for _, mod in list(sys.modules.items()): 57 | try: 58 | mod.__warningregistry__.clear() 59 | except AttributeError: 60 | pass 61 | 62 | with warnings.catch_warnings(record=True) as w, mock.patch.dict( 63 | os.environ, {"TRAITLETS_ALL_DEPRECATIONS": "1"} 64 | ): 65 | warnings.simplefilter("always") 66 | yield w 67 | 68 | 69 | @contextmanager 70 | def expected_warnings(matching): 71 | r"""Context for use in testing to catch known warnings matching regexes 72 | 73 | Parameters 74 | ---------- 75 | matching : list of strings or compiled regexes 76 | Regexes for the desired warning to catch 77 | 78 | Examples 79 | -------- 80 | >>> from skimage import data, img_as_ubyte, img_as_float # doctest: +SKIP 81 | >>> with expected_warnings(["precision loss"]): # doctest: +SKIP 82 | ... d = img_as_ubyte(img_as_float(data.coins())) # doctest: +SKIP 83 | 84 | Notes 85 | ----- 86 | Uses `all_warnings` to ensure all warnings are raised. 87 | Upon exiting, it checks the recorded warnings for the desired matching 88 | pattern(s). 89 | Raises a ValueError if any match was not found or an unexpected 90 | warning was raised. 91 | Allows for three types of behaviors: "and", "or", and "optional" matches. 92 | This is done to accommodate different build environments or loop conditions 93 | that may produce different warnings. The behaviors can be combined. 94 | If you pass multiple patterns, you get an orderless "and", where all of the 95 | warnings must be raised. 96 | If you use the "|" operator in a pattern, you can catch one of several warnings. 97 | Finally, you can use "|\A\Z" in a pattern to signify it as optional. 98 | """ 99 | with all_warnings() as w: 100 | # enter context 101 | yield w 102 | # exited user context, check the recorded warnings 103 | remaining = [m for m in matching if r"\A\Z" not in m.split("|")] 104 | for warn in w: 105 | found = False 106 | for match in matching: 107 | if re.search(match, str(warn.message)) is not None: 108 | found = True 109 | if match in remaining: 110 | remaining.remove(match) 111 | if not found: 112 | raise ValueError("Unexpected warning: %s" % str(warn.message)) 113 | if len(remaining) > 0: 114 | msg = "No warning raised matching:\n%s" % "\n".join(remaining) 115 | raise ValueError(msg) 116 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: "0 8 * * *" 8 | 9 | concurrency: 10 | group: >- 11 | ${{ github.workflow }}- 12 | ${{ github.ref_type }}- 13 | ${{ github.event.pull_request.number || github.sha }} 14 | cancel-in-progress: true 15 | 16 | defaults: 17 | run: 18 | shell: bash -eux {0} 19 | 20 | jobs: 21 | tests: 22 | runs-on: ${{ matrix.os }} 23 | strategy: 24 | fail-fast: false 25 | matrix: 26 | os: [ubuntu-latest, windows-latest, macos-latest] 27 | python-version: ["3.9", "3.13", "3.14", "3.14t"] 28 | include: 29 | - os: ubuntu-latest 30 | python-version: "3.10" 31 | - os: ubuntu-latest 32 | python-version: "3.11" 33 | - os: ubuntu-latest 34 | python-version: "3.12" 35 | - os: ubuntu-latest 36 | python-version: "pypy-3.10" 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 41 | - name: Run Tests 42 | run: hatch run cov:test 43 | - uses: jupyterlab/maintainer-tools/.github/actions/upload-coverage@v1 44 | 45 | coverage: 46 | runs-on: ubuntu-latest 47 | needs: 48 | - tests 49 | steps: 50 | - uses: actions/checkout@v4 51 | - uses: jupyterlab/maintainer-tools/.github/actions/report-coverage@v1 52 | 53 | test_minimum_versions: 54 | name: Test Minimum Versions 55 | runs-on: ubuntu-latest 56 | timeout-minutes: 10 57 | steps: 58 | - uses: actions/checkout@v4 59 | - name: Base Setup 60 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 61 | with: 62 | dependency_type: minimum 63 | - name: Run the unit tests 64 | run: | 65 | hatch run test:nowarn || hatch -v run test:nowarn --lf 66 | 67 | test_lint: 68 | name: Test Lint 69 | runs-on: ubuntu-latest 70 | steps: 71 | - uses: actions/checkout@v4 72 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 73 | - name: Run Linters 74 | run: | 75 | hatch run typing:test 76 | hatch run lint:build 77 | pipx run 'validate-pyproject[all]' pyproject.toml 78 | pipx run doc8 --max-line-length=200 79 | 80 | test_docs: 81 | timeout-minutes: 10 82 | runs-on: ubuntu-latest 83 | steps: 84 | - uses: actions/checkout@v4 85 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 86 | - name: Build the docs 87 | run: hatch run docs:build 88 | 89 | test_prereleases: 90 | name: Test Prereleases 91 | timeout-minutes: 10 92 | runs-on: ubuntu-latest 93 | steps: 94 | - name: Checkout 95 | uses: actions/checkout@v4 96 | - name: Base Setup 97 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 98 | with: 99 | dependency_type: pre 100 | - name: Run the tests 101 | run: | 102 | hatch run test:nowarn || hatch run test:nowarn --lf 103 | 104 | make_sdist: 105 | name: Make SDist 106 | runs-on: ubuntu-latest 107 | timeout-minutes: 10 108 | steps: 109 | - uses: actions/checkout@v4 110 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 111 | - uses: jupyterlab/maintainer-tools/.github/actions/make-sdist@v1 112 | 113 | test_sdist: 114 | runs-on: ubuntu-latest 115 | needs: [make_sdist] 116 | name: Install from SDist and Test 117 | timeout-minutes: 20 118 | steps: 119 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 120 | - uses: jupyterlab/maintainer-tools/.github/actions/test-sdist@v1 121 | 122 | check_links: 123 | runs-on: ubuntu-latest 124 | timeout-minutes: 10 125 | steps: 126 | - uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 127 | - uses: jupyterlab/maintainer-tools/.github/actions/check-links@v1 128 | 129 | check_release: 130 | runs-on: ubuntu-latest 131 | steps: 132 | - name: Checkout 133 | uses: actions/checkout@v4 134 | - name: Base Setup 135 | uses: jupyterlab/maintainer-tools/.github/actions/base-setup@v1 136 | - name: Install Dependencies 137 | run: | 138 | pip install -e . 139 | - name: Check Release 140 | uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 141 | with: 142 | token: ${{ secrets.GITHUB_TOKEN }} 143 | 144 | tests_check: # This job does nothing and is only used for the branch protection 145 | if: always() 146 | needs: 147 | - coverage 148 | - test_lint 149 | - test_docs 150 | - test_minimum_versions 151 | - test_prereleases 152 | - check_links 153 | - check_release 154 | - test_sdist 155 | runs-on: ubuntu-latest 156 | steps: 157 | - name: Decide whether the needed jobs succeeded or failed 158 | uses: re-actors/alls-green@release/v1 159 | with: 160 | jobs: ${{ toJSON(needs) }} 161 | -------------------------------------------------------------------------------- /tests/utils/test_decorators.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from inspect import Parameter, signature 4 | from unittest import TestCase 5 | 6 | from traitlets import HasTraits, Int, Unicode 7 | from traitlets.utils.decorators import signature_has_traits 8 | 9 | 10 | class TestExpandSignature(TestCase): 11 | def test_no_init(self): 12 | @signature_has_traits 13 | class Foo(HasTraits): 14 | number1 = Int() 15 | number2 = Int() 16 | value = Unicode("Hello") 17 | 18 | parameters = signature(Foo).parameters 19 | parameter_names = list(parameters) 20 | 21 | self.assertIs(parameters["args"].kind, Parameter.VAR_POSITIONAL) 22 | self.assertEqual("args", parameter_names[0]) 23 | 24 | self.assertIs(parameters["number1"].kind, Parameter.KEYWORD_ONLY) 25 | self.assertIs(parameters["number2"].kind, Parameter.KEYWORD_ONLY) 26 | self.assertIs(parameters["value"].kind, Parameter.KEYWORD_ONLY) 27 | 28 | self.assertIs(parameters["kwargs"].kind, Parameter.VAR_KEYWORD) 29 | self.assertEqual("kwargs", parameter_names[-1]) 30 | 31 | f = Foo(number1=32, value="World") 32 | self.assertEqual(f.number1, 32) 33 | self.assertEqual(f.number2, 0) 34 | self.assertEqual(f.value, "World") 35 | 36 | def test_partial_init(self): 37 | @signature_has_traits 38 | class Foo(HasTraits): 39 | number1 = Int() 40 | number2 = Int() 41 | value = Unicode("Hello") 42 | 43 | def __init__(self, arg1, **kwargs): 44 | self.arg1 = arg1 45 | 46 | super().__init__(**kwargs) 47 | 48 | parameters = signature(Foo).parameters 49 | parameter_names = list(parameters) 50 | 51 | self.assertIs(parameters["arg1"].kind, Parameter.POSITIONAL_OR_KEYWORD) 52 | self.assertEqual("arg1", parameter_names[0]) 53 | 54 | self.assertIs(parameters["number1"].kind, Parameter.KEYWORD_ONLY) 55 | self.assertIs(parameters["number2"].kind, Parameter.KEYWORD_ONLY) 56 | self.assertIs(parameters["value"].kind, Parameter.KEYWORD_ONLY) 57 | 58 | self.assertIs(parameters["kwargs"].kind, Parameter.VAR_KEYWORD) 59 | self.assertEqual("kwargs", parameter_names[-1]) 60 | 61 | f = Foo(1, number1=32, value="World") 62 | self.assertEqual(f.arg1, 1) 63 | self.assertEqual(f.number1, 32) 64 | self.assertEqual(f.number2, 0) 65 | self.assertEqual(f.value, "World") 66 | 67 | def test_duplicate_init(self): 68 | @signature_has_traits 69 | class Foo(HasTraits): 70 | number1 = Int() 71 | number2 = Int() 72 | 73 | def __init__(self, number1, **kwargs): 74 | self.test = number1 75 | 76 | super().__init__(number1=number1, **kwargs) 77 | 78 | parameters = signature(Foo).parameters 79 | parameter_names = list(parameters) 80 | 81 | self.assertListEqual(parameter_names, ["number1", "number2", "kwargs"]) 82 | 83 | f = Foo(number1=32, number2=36) 84 | self.assertEqual(f.test, 32) 85 | self.assertEqual(f.number1, 32) 86 | self.assertEqual(f.number2, 36) 87 | 88 | def test_full_init(self): 89 | @signature_has_traits 90 | class Foo(HasTraits): 91 | number1 = Int() 92 | number2 = Int() 93 | value = Unicode("Hello") 94 | 95 | def __init__(self, arg1, arg2=None, *pos_args, **kw_args): 96 | self.arg1 = arg1 97 | self.arg2 = arg2 98 | self.pos_args = pos_args 99 | self.kw_args = kw_args 100 | 101 | super().__init__(*pos_args, **kw_args) 102 | 103 | parameters = signature(Foo).parameters 104 | parameter_names = list(parameters) 105 | 106 | self.assertIs(parameters["arg1"].kind, Parameter.POSITIONAL_OR_KEYWORD) 107 | self.assertEqual("arg1", parameter_names[0]) 108 | 109 | self.assertIs(parameters["arg2"].kind, Parameter.POSITIONAL_OR_KEYWORD) 110 | self.assertEqual("arg2", parameter_names[1]) 111 | 112 | self.assertIs(parameters["pos_args"].kind, Parameter.VAR_POSITIONAL) 113 | self.assertEqual("pos_args", parameter_names[2]) 114 | 115 | self.assertIs(parameters["number1"].kind, Parameter.KEYWORD_ONLY) 116 | self.assertIs(parameters["number2"].kind, Parameter.KEYWORD_ONLY) 117 | self.assertIs(parameters["value"].kind, Parameter.KEYWORD_ONLY) 118 | 119 | self.assertIs(parameters["kw_args"].kind, Parameter.VAR_KEYWORD) 120 | self.assertEqual("kw_args", parameter_names[-1]) 121 | 122 | f = Foo(1, 3, 45, "hey", number1=32, value="World") 123 | self.assertEqual(f.arg1, 1) 124 | self.assertEqual(f.arg2, 3) 125 | self.assertTupleEqual(f.pos_args, (45, "hey")) 126 | self.assertEqual(f.number1, 32) 127 | self.assertEqual(f.number2, 0) 128 | self.assertEqual(f.value, "World") 129 | 130 | def test_no_kwargs(self): 131 | with self.assertRaises(RuntimeError): 132 | 133 | @signature_has_traits 134 | class Foo(HasTraits): 135 | number1 = Int() 136 | number2 = Int() 137 | 138 | def __init__(self, arg1, arg2=None): 139 | pass 140 | -------------------------------------------------------------------------------- /traitlets/config/sphinxdoc.py: -------------------------------------------------------------------------------- 1 | """Machinery for documenting traitlets config options with Sphinx. 2 | 3 | This includes: 4 | 5 | - A Sphinx extension defining directives and roles for config options. 6 | - A function to generate an rst file given an Application instance. 7 | 8 | To make this documentation, first set this module as an extension in Sphinx's 9 | conf.py:: 10 | 11 | extensions = [ 12 | # ... 13 | 'traitlets.config.sphinxdoc', 14 | ] 15 | 16 | Autogenerate the config documentation by running code like this before 17 | Sphinx builds:: 18 | 19 | from traitlets.config.sphinxdoc import write_doc 20 | from myapp import MyApplication 21 | 22 | writedoc('config/options.rst', # File to write 23 | 'MyApp config options', # Title 24 | MyApplication() 25 | ) 26 | 27 | The generated rST syntax looks like this:: 28 | 29 | .. configtrait:: Application.log_datefmt 30 | 31 | Description goes here. 32 | 33 | Cross reference like this: :configtrait:`Application.log_datefmt`. 34 | """ 35 | from __future__ import annotations 36 | 37 | import typing as t 38 | from collections import defaultdict 39 | from textwrap import dedent 40 | 41 | from traitlets import HasTraits, Undefined 42 | from traitlets.config.application import Application 43 | from traitlets.utils.text import indent 44 | 45 | 46 | def setup(app: t.Any) -> dict[str, t.Any]: 47 | """Registers the Sphinx extension. 48 | 49 | You shouldn't need to call this directly; configure Sphinx to use this 50 | module instead. 51 | """ 52 | app.add_object_type("configtrait", "configtrait", objname="Config option") 53 | return {"parallel_read_safe": True, "parallel_write_safe": True} 54 | 55 | 56 | def interesting_default_value(dv: t.Any) -> bool: 57 | if (dv is None) or (dv is Undefined): 58 | return False 59 | if isinstance(dv, (str, list, tuple, dict, set)): 60 | return bool(dv) 61 | return True 62 | 63 | 64 | def format_aliases(aliases: list[str]) -> str: 65 | fmted = [] 66 | for a in aliases: 67 | dashes = "-" if len(a) == 1 else "--" 68 | fmted.append(f"``{dashes}{a}``") 69 | return ", ".join(fmted) 70 | 71 | 72 | def class_config_rst_doc(cls: type[HasTraits], trait_aliases: dict[str, t.Any]) -> str: 73 | """Generate rST documentation for this class' config options. 74 | 75 | Excludes traits defined on parent classes. 76 | """ 77 | lines = [] 78 | classname = cls.__name__ 79 | for _, trait in sorted(cls.class_traits(config=True).items()): 80 | ttype = trait.__class__.__name__ 81 | 82 | fullname = classname + "." + (trait.name or "") 83 | lines += [".. configtrait:: " + fullname, ""] 84 | 85 | help = trait.help.rstrip() or "No description" 86 | lines.append(indent(dedent(help)) + "\n") 87 | 88 | # Choices or type 89 | if "Enum" in ttype: 90 | # include Enum choices 91 | lines.append(indent(":options: " + ", ".join("``%r``" % x for x in trait.values))) # type:ignore[attr-defined] 92 | else: 93 | lines.append(indent(":trait type: " + ttype)) 94 | 95 | # Default value 96 | # Ignore boring default values like None, [] or '' 97 | if interesting_default_value(trait.default_value): 98 | try: 99 | dvr = trait.default_value_repr() 100 | except Exception: 101 | dvr = None # ignore defaults we can't construct 102 | if dvr is not None: 103 | if len(dvr) > 64: 104 | dvr = dvr[:61] + "..." 105 | # Double up backslashes, so they get to the rendered docs 106 | dvr = dvr.replace("\\n", "\\\\n") 107 | lines.append(indent(":default: ``%s``" % dvr)) 108 | 109 | # Command line aliases 110 | if trait_aliases[fullname]: 111 | fmt_aliases = format_aliases(trait_aliases[fullname]) 112 | lines.append(indent(":CLI option: " + fmt_aliases)) 113 | 114 | # Blank line 115 | lines.append("") 116 | 117 | return "\n".join(lines) 118 | 119 | 120 | def reverse_aliases(app: Application) -> dict[str, list[str]]: 121 | """Produce a mapping of trait names to lists of command line aliases.""" 122 | res = defaultdict(list) 123 | for alias, trait in app.aliases.items(): 124 | res[trait].append(alias) 125 | 126 | # Flags also often act as aliases for a boolean trait. 127 | # Treat flags which set one trait to True as aliases. 128 | for flag, (cfg, _) in app.flags.items(): 129 | if len(cfg) == 1: 130 | classname = next(iter(cfg)) 131 | cls_cfg = cfg[classname] 132 | if len(cls_cfg) == 1: 133 | traitname = next(iter(cls_cfg)) 134 | if cls_cfg[traitname] is True: 135 | res[classname + "." + traitname].append(flag) 136 | 137 | return res 138 | 139 | 140 | def write_doc(path: str, title: str, app: Application, preamble: str | None = None) -> None: 141 | """Write a rst file documenting config options for a traitlets application. 142 | 143 | Parameters 144 | ---------- 145 | path : str 146 | The file to be written 147 | title : str 148 | The human-readable title of the document 149 | app : traitlets.config.Application 150 | An instance of the application class to be documented 151 | preamble : str 152 | Extra text to add just after the title (optional) 153 | """ 154 | trait_aliases = reverse_aliases(app) 155 | with open(path, "w") as f: 156 | f.write(title + "\n") 157 | f.write(("=" * len(title)) + "\n") 158 | f.write("\n") 159 | if preamble is not None: 160 | f.write(preamble + "\n\n") 161 | 162 | for c in app._classes_inc_parents(): 163 | f.write(class_config_rst_doc(c, trait_aliases)) 164 | f.write("\n") 165 | -------------------------------------------------------------------------------- /examples/argcomplete_app.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | """A simple example to test CLI completion with `traitlets.Application` 4 | 5 | Follow the installation instructions in 6 | https://github.com/kislyuk/argcomplete#installation 7 | to install argcomplete. For example for bash, you can set up global completion 8 | via something like:: 9 | 10 | $ activate-global-python-argcomplete --dest=~/.bash_completion.d 11 | 12 | and ``source ~/.bash_completion.d`` in your ``~/.bashrc``. To use the 13 | `global-python-argcomplete`, your `traitlets.Application`-based script should 14 | have the string ``PYTHON_ARGCOMPLETE_OK`` in the first few lines of the script. 15 | 16 | Afterwards, try tab completing options to this script:: 17 | 18 | # Option completion, show flags, aliases and --Class. 19 | $ examples/argcomplete_app.py --[TAB] 20 | --Application. --JsonPrinter. --env-vars --s 21 | --ArgcompleteApp. --e --help --skip-if-missing 22 | --EnvironPrinter. --env-var --json-indent --style 23 | 24 | $ examples/argcomplete_app.py --A[TAB] 25 | --Application. --ArgcompleteApp. 26 | 27 | # Complete class config traits 28 | $ examples/argcomplete_app.py --EnvironPrinter.[TAB] 29 | --EnvironPrinter.no_complete --EnvironPrinter.style 30 | --EnvironPrinter.skip_if_missing --EnvironPrinter.vars 31 | 32 | # Using argcomplete's provided EnvironCompleter 33 | $ examples/argcomplete_app.py --EnvironPrinter.vars=[TAB] 34 | APPDATA LS_COLORS 35 | COMP_LINE NAME 36 | COMP_POINT OLDPWD 37 | COMP_TYPE PATH 38 | ... 39 | 40 | $ examples/argcomplete_app.py --EnvironPrinter.vars USER [TAB] 41 | APPDATA LS_COLORS 42 | COMP_LINE NAME 43 | COMP_POINT OLDPWD 44 | COMP_TYPE PATH 45 | ... 46 | 47 | # Alias for --EnvironPrinter.vars 48 | $ examples/argcomplete_app.py --env-vars P[TAB] 49 | PATH PWD PYTHONPATH 50 | 51 | # Custom completer example 52 | $ examples/argcomplete_app.py --env-vars PWD --json-indent [TAB] 53 | 2 4 8 54 | 55 | # Enum completer example 56 | $ examples/argcomplete_app.py --style [TAB] 57 | ndjson posix verbose 58 | 59 | # Bool completer example 60 | $ examples/argcomplete_app.py --Application.show_config_json [TAB] 61 | 0 1 false true 62 | 63 | If completions are not showing, you can set the environment variable ``_ARC_DEBUG=1`` 64 | to assist in debugging argcomplete. This was last checked with ``argcomplete==1.12.3``. 65 | """ 66 | from __future__ import annotations 67 | 68 | import json 69 | import os 70 | 71 | try: 72 | from argcomplete.completers import EnvironCompleter, SuppressCompleter 73 | except ImportError: 74 | EnvironCompleter = SuppressCompleter = None 75 | from traitlets import Bool, Enum, Int, List, Unicode 76 | from traitlets.config.application import Application 77 | from traitlets.config.configurable import Configurable 78 | 79 | 80 | def _indent_completions(**kwargs): 81 | """Example of a custom completer, which could be dynamic""" 82 | return ["2", "4", "8"] 83 | 84 | 85 | class JsonPrinter(Configurable): 86 | indent = Int(None, allow_none=True).tag(config=True, argcompleter=_indent_completions) 87 | 88 | def print(self, obj): 89 | print(json.dumps(obj, indent=self.indent)) 90 | 91 | 92 | class EnvironPrinter(Configurable): 93 | """A class that has configurable, typed attributes.""" 94 | 95 | vars = List(trait=Unicode(), help="Environment variable").tag( 96 | # NOTE: currently multiplicity is ignored by the traitlets CLI. 97 | # Refer to issue GH#690 for discussion 98 | config=True, 99 | multiplicity="+", 100 | argcompleter=EnvironCompleter, 101 | ) 102 | no_complete = Unicode().tag(config=True, argcompleter=SuppressCompleter) 103 | style = Enum(values=["posix", "ndjson", "verbose"], default_value="posix").tag(config=True) 104 | skip_if_missing = Bool(False, help="Skip variable if not set").tag(config=True) 105 | 106 | def print(self): 107 | for env_var in self.vars: 108 | if env_var not in os.environ: 109 | if self.skip_if_missing: 110 | continue 111 | raise KeyError(f"Environment variable not set: {env_var}") 112 | 113 | value = os.environ[env_var] 114 | if self.style == "posix": 115 | print(f"{env_var}={value}") 116 | elif self.style == "verbose": 117 | print(f">> key: {env_var} value:\n{value}\n") 118 | elif self.style == "ndjson": 119 | JsonPrinter(parent=self).print({"key": env_var, "value": value}) 120 | 121 | 122 | def bool_flag(trait, value=True): 123 | return ({trait.this_class.__name__: {trait.name: value}}, trait.help) 124 | 125 | 126 | class ArgcompleteApp(Application): 127 | name = Unicode("argcomplete-example-app") 128 | description = Unicode("prints requested environment variables") 129 | classes = [JsonPrinter, EnvironPrinter] 130 | 131 | config_file = Unicode("", help="Load this config file").tag(config=True) 132 | 133 | aliases = { 134 | ("e", "env-var", "env-vars"): "EnvironPrinter.vars", 135 | ("s", "style"): "EnvironPrinter.style", 136 | ("json-indent"): "JsonPrinter.indent", 137 | } 138 | 139 | flags = { 140 | "skip-if-missing": bool_flag(EnvironPrinter.skip_if_missing), 141 | } 142 | 143 | def initialize(self, argv=None): 144 | self.parse_command_line(argv) 145 | if self.config_file: 146 | self.load_config_file(self.config_file) 147 | 148 | def start(self): 149 | EnvironPrinter(parent=self).print() 150 | 151 | 152 | if __name__ == "__main__": 153 | ArgcompleteApp.launch_instance() 154 | -------------------------------------------------------------------------------- /examples/myapp.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # PYTHON_ARGCOMPLETE_OK 3 | """A simple example of how to use traitlets.config.application.Application. 4 | 5 | This should serve as a simple example that shows how the traitlets config 6 | system works. The main classes are: 7 | 8 | * traitlets.config.Configurable 9 | * traitlets.config.SingletonConfigurable 10 | * traitlets.config.Config 11 | * traitlets.config.Application 12 | 13 | To see the command line option help, run this program from the command line:: 14 | 15 | $ python myapp.py -h 16 | 17 | To make one of your classes configurable (from the command line and config 18 | files) inherit from Configurable and declare class attributes as traits (see 19 | classes Foo and Bar below). To make the traits configurable, you will need 20 | to set the following options: 21 | 22 | * ``config``: set to ``True`` to make the attribute configurable. 23 | * ``shortname``: by default, configurable attributes are set using the syntax 24 | "Classname.attributename". At the command line, this is a bit verbose, so 25 | we allow "shortnames" to be declared. Setting a shortname is optional, but 26 | when you do this, you can set the option at the command line using the 27 | syntax: "shortname=value". 28 | * ``help``: set the help string to display a help message when the ``-h`` 29 | option is given at the command line. The help string should be valid ReST. 30 | 31 | When the config attribute of an Application is updated, it will fire all of 32 | the trait's events for all of the config=True attributes. 33 | """ 34 | from __future__ import annotations 35 | 36 | from traitlets import Bool, Dict, Enum, Int, List, Unicode 37 | from traitlets.config.application import Application 38 | from traitlets.config.configurable import Configurable 39 | 40 | 41 | class SubConfigurable(Configurable): 42 | subvalue = Int(0, help="The integer subvalue.").tag(config=True) 43 | 44 | def describe(self): 45 | print("I am SubConfigurable with:") 46 | print(" subvalue =", self.subvalue) 47 | 48 | 49 | class Foo(Configurable): 50 | """A class that has configurable, typed attributes.""" 51 | 52 | i = Int(0, help="The integer i.").tag(config=True) 53 | j = Int(1, help="The integer j.").tag(config=True) 54 | name = Unicode("Brian", help="First name.").tag(config=True, shortname="B") 55 | mode = Enum(values=["on", "off", "other"], default_value="on").tag(config=True) 56 | 57 | def __init__(self, **kwargs): 58 | super().__init__(**kwargs) 59 | # using parent=self allows configuration in the form c.Foo.SubConfigurable.subvalue=1 60 | # while c.SubConfigurable.subvalue=1 will still work, this allow to 61 | # target specific instances of SubConfigurables 62 | self.subconf = SubConfigurable(parent=self) 63 | 64 | def describe(self): 65 | print("I am Foo with:") 66 | print(" i =", self.i) 67 | print(" j =", self.j) 68 | print(" name =", self.name) 69 | print(" mode =", self.mode) 70 | self.subconf.describe() 71 | 72 | 73 | class Bar(Configurable): 74 | enabled = Bool(True, help="Enable bar.").tag(config=True) 75 | mylist = List([1, 2, 3], help="Just a list.").tag(config=True) 76 | 77 | def describe(self): 78 | print("I am Bar with:") 79 | print(" enabled = ", self.enabled) 80 | print(" mylist = ", self.mylist) 81 | self.subconf.describe() 82 | 83 | def __init__(self, **kwargs): 84 | super().__init__(**kwargs) 85 | # here we do not use parent=self, so configuration in the form 86 | # c.Bar.SubConfigurable.subvalue=1 will not work. Only 87 | # c.SubConfigurable.subvalue=1 will work and affect all instances of 88 | # SubConfigurable 89 | self.subconf = SubConfigurable(config=self.config) 90 | 91 | 92 | class MyApp(Application): 93 | name = Unicode("myapp") 94 | running = Bool(False, help="Is the app running?").tag(config=True) 95 | classes = List([Bar, Foo]) # type:ignore[assignment] 96 | config_file = Unicode("", help="Load this config file").tag(config=True) 97 | 98 | aliases = Dict( # type:ignore[assignment] 99 | dict( # noqa: C408 100 | i="Foo.i", 101 | j="Foo.j", 102 | name="Foo.name", 103 | mode="Foo.mode", 104 | running="MyApp.running", 105 | enabled="Bar.enabled", 106 | log_level="MyApp.log_level", 107 | ) 108 | ) 109 | 110 | flags = Dict( # type:ignore[assignment] 111 | dict( # noqa: C408 112 | enable=({"Bar": {"enabled": True}}, "Enable Bar"), 113 | disable=({"Bar": {"enabled": False}}, "Disable Bar"), 114 | debug=({"MyApp": {"log_level": 10}}, "Set loglevel to DEBUG"), 115 | ) 116 | ) 117 | 118 | def init_foo(self): 119 | # You can pass self as parent to automatically propagate config. 120 | self.foo = Foo(parent=self) 121 | 122 | def init_bar(self): 123 | # Pass config to other classes for them to inherit the config. 124 | self.bar = Bar(config=self.config) 125 | 126 | def initialize(self, argv=None): 127 | self.parse_command_line(argv) 128 | if self.config_file: 129 | self.load_config_file(self.config_file) 130 | self.load_config_environ() 131 | self.init_foo() 132 | self.init_bar() 133 | 134 | def start(self): 135 | print("app.config:") 136 | print(self.config) 137 | self.describe() 138 | print("try running with --help-all to see all available flags") 139 | assert self.log is not None 140 | self.log.debug("Debug Message") 141 | self.log.info("Info Message") 142 | self.log.warning("Warning Message") 143 | self.log.critical("Critical Message") 144 | 145 | def describe(self): 146 | print("I am MyApp with", self.name, self.running, "and 2 sub configurables Foo and bar:") 147 | self.foo.describe() 148 | self.bar.describe() 149 | 150 | 151 | def main(): 152 | app = MyApp() 153 | app.initialize() 154 | app.start() 155 | 156 | 157 | if __name__ == "__main__": 158 | main() 159 | -------------------------------------------------------------------------------- /traitlets/utils/descriptions.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import inspect 4 | import re 5 | import types 6 | from typing import Any 7 | 8 | 9 | def describe( 10 | article: str | None, 11 | value: Any, 12 | name: str | None = None, 13 | verbose: bool = False, 14 | capital: bool = False, 15 | ) -> str: 16 | """Return string that describes a value 17 | 18 | Parameters 19 | ---------- 20 | article : str or None 21 | A definite or indefinite article. If the article is 22 | indefinite (i.e. "a" or "an") the appropriate one 23 | will be inferred. Thus, the arguments of ``describe`` 24 | can themselves represent what the resulting string 25 | will actually look like. If None, then no article 26 | will be prepended to the result. For non-articled 27 | description, values that are instances are treated 28 | definitely, while classes are handled indefinitely. 29 | value : any 30 | The value which will be named. 31 | name : str or None (default: None) 32 | Only applies when ``article`` is "the" - this 33 | ``name`` is a definite reference to the value. 34 | By default one will be inferred from the value's 35 | type and repr methods. 36 | verbose : bool (default: False) 37 | Whether the name should be concise or verbose. When 38 | possible, verbose names include the module, and/or 39 | class name where an object was defined. 40 | capital : bool (default: False) 41 | Whether the first letter of the article should 42 | be capitalized or not. By default it is not. 43 | 44 | Examples 45 | -------- 46 | Indefinite description: 47 | 48 | >>> describe("a", object()) 49 | 'an object' 50 | >>> describe("a", object) 51 | 'an object' 52 | >>> describe("a", type(object)) 53 | 'a type' 54 | 55 | Definite description: 56 | 57 | >>> describe("the", object()) 58 | "the object at '...'" 59 | >>> describe("the", object) 60 | 'the object object' 61 | >>> describe("the", type(object)) 62 | 'the type type' 63 | 64 | Definitely named description: 65 | 66 | >>> describe("the", object(), "I made") 67 | 'the object I made' 68 | >>> describe("the", object, "I will use") 69 | 'the object I will use' 70 | """ 71 | if isinstance(article, str): 72 | article = article.lower() 73 | 74 | if not inspect.isclass(value): 75 | typename = type(value).__name__ 76 | else: 77 | typename = value.__name__ 78 | if verbose: 79 | typename = _prefix(value) + typename 80 | 81 | if article == "the" or (article is None and not inspect.isclass(value)): 82 | if name is not None: 83 | result = f"{typename} {name}" 84 | if article is not None: 85 | return add_article(result, True, capital) 86 | else: 87 | return result 88 | else: 89 | tick_wrap = False 90 | if inspect.isclass(value): 91 | name = value.__name__ 92 | elif isinstance(value, types.FunctionType): 93 | name = value.__name__ 94 | tick_wrap = True 95 | elif isinstance(value, types.MethodType): 96 | name = value.__func__.__name__ 97 | tick_wrap = True 98 | elif type(value).__repr__ in ( 99 | object.__repr__, 100 | type.__repr__, 101 | ): # type:ignore[comparison-overlap] 102 | name = "at '%s'" % hex(id(value)) 103 | verbose = False 104 | else: 105 | name = repr(value) 106 | verbose = False 107 | if verbose: 108 | name = _prefix(value) + name 109 | if tick_wrap: 110 | name = name.join("''") 111 | return describe(article, value, name=name, verbose=verbose, capital=capital) 112 | elif article in ("a", "an") or article is None: 113 | if article is None: 114 | return typename 115 | return add_article(typename, False, capital) 116 | else: 117 | raise ValueError( 118 | "The 'article' argument should be 'the', 'a', 'an', or None not %r" % article 119 | ) 120 | 121 | 122 | def _prefix(value: Any) -> str: 123 | if isinstance(value, types.MethodType): 124 | name = describe(None, value.__self__, verbose=True) + "." 125 | else: 126 | module = inspect.getmodule(value) 127 | if module is not None and module.__name__ != "builtins": 128 | name = module.__name__ + "." 129 | else: 130 | name = "" 131 | return name 132 | 133 | 134 | def class_of(value: Any) -> Any: 135 | """Returns a string of the value's type with an indefinite article. 136 | 137 | For example 'an Image' or 'a PlotValue'. 138 | """ 139 | if inspect.isclass(value): 140 | return add_article(value.__name__) 141 | else: 142 | return class_of(type(value)) 143 | 144 | 145 | def add_article(name: str, definite: bool = False, capital: bool = False) -> str: 146 | """Returns the string with a prepended article. 147 | 148 | The input does not need to begin with a character. 149 | 150 | Parameters 151 | ---------- 152 | name : str 153 | Name to which to prepend an article 154 | definite : bool (default: False) 155 | Whether the article is definite or not. 156 | Indefinite articles being 'a' and 'an', 157 | while 'the' is definite. 158 | capital : bool (default: False) 159 | Whether the added article should have 160 | its first letter capitalized or not. 161 | """ 162 | if definite: 163 | result = "the " + name 164 | else: 165 | first_letters = re.compile(r"[\W_]+").sub("", name) 166 | if first_letters[:1].lower() in "aeiou": 167 | result = "an " + name 168 | else: 169 | result = "a " + name 170 | if capital: 171 | return result[0].upper() + result[1:] 172 | else: 173 | return result 174 | 175 | 176 | def repr_type(obj: Any) -> str: 177 | """Return a string representation of a value and its type for readable 178 | 179 | error messages. 180 | """ 181 | the_type = type(obj) 182 | return f"{obj!r} {the_type!r}" 183 | -------------------------------------------------------------------------------- /docs/sphinxext/github.py: -------------------------------------------------------------------------------- 1 | """Define text roles for GitHub 2 | 3 | * ghissue - Issue 4 | * ghpull - Pull Request 5 | * ghuser - User 6 | 7 | Adapted from bitbucket example here: 8 | https://bitbucket.org/birkenfeld/sphinx-contrib/src/tip/bitbucket/sphinxcontrib/bitbucket.py 9 | 10 | Authors 11 | ------- 12 | 13 | * Doug Hellmann 14 | * Min RK 15 | """ 16 | # 17 | # Original Copyright (c) 2010 Doug Hellmann. All rights reserved. 18 | # 19 | from __future__ import annotations 20 | 21 | from docutils import nodes, utils 22 | from docutils.parsers.rst.roles import set_classes 23 | from sphinx.util.logging import getLogger 24 | 25 | info = getLogger(__name__).info 26 | 27 | 28 | def make_link_node(rawtext, app, type, slug, options): 29 | """Create a link to a github resource. 30 | 31 | :param rawtext: Text being replaced with link node. 32 | :param app: Sphinx application context 33 | :param type: Link type (issues, changeset, etc.) 34 | :param slug: ID of the thing to link to 35 | :param options: Options dictionary passed to role func. 36 | """ 37 | 38 | try: 39 | base = app.config.github_project_url 40 | if not base: 41 | raise AttributeError 42 | if not base.endswith("/"): 43 | base += "/" 44 | except AttributeError as err: 45 | raise ValueError( 46 | "github_project_url configuration value is not set (%s)" % str(err) 47 | ) from err 48 | 49 | ref = base + type + "/" + slug + "/" 50 | set_classes(options) 51 | prefix = "#" 52 | if type == "pull": 53 | prefix = "PR " + prefix 54 | return nodes.reference(rawtext, prefix + utils.unescape(slug), refuri=ref, **options) 55 | 56 | 57 | def ghissue_role(name, rawtext, text, lineno, inliner, options=None, content=None): 58 | """Link to a GitHub issue. 59 | 60 | Returns 2 part tuple containing list of nodes to insert into the 61 | document and a list of system messages. Both are allowed to be 62 | empty. 63 | 64 | :param name: The role name used in the document. 65 | :param rawtext: The entire markup snippet, with role. 66 | :param text: The text marked with the role. 67 | :param lineno: The line number where rawtext appears in the input. 68 | :param inliner: The inliner instance that called us. 69 | :param options: Directive options for customization. 70 | :param content: The directive content for customization. 71 | """ 72 | options = options or {} 73 | content = content or [] 74 | 75 | try: 76 | issue_num = int(text) 77 | if issue_num <= 0: 78 | raise ValueError 79 | except ValueError: 80 | msg = inliner.reporter.error( 81 | "GitHub issue number must be a number greater than or equal to 1; " 82 | '"%s" is invalid.' % text, 83 | line=lineno, 84 | ) 85 | prb = inliner.problematic(rawtext, rawtext, msg) 86 | return [prb], [msg] 87 | app = inliner.document.settings.env.app 88 | # info('issue %r' % text) 89 | if "pull" in name.lower(): 90 | category = "pull" 91 | elif "issue" in name.lower(): 92 | category = "issues" 93 | else: 94 | msg = inliner.reporter.error( 95 | 'GitHub roles include "ghpull" and "ghissue", "%s" is invalid.' % name, line=lineno 96 | ) 97 | prb = inliner.problematic(rawtext, rawtext, msg) 98 | return [prb], [msg] 99 | node = make_link_node(rawtext, app, category, str(issue_num), options) 100 | return [node], [] 101 | 102 | 103 | def ghuser_role(name, rawtext, text, lineno, inliner, options=None, content=None): 104 | """Link to a GitHub user. 105 | 106 | Returns 2 part tuple containing list of nodes to insert into the 107 | document and a list of system messages. Both are allowed to be 108 | empty. 109 | 110 | :param name: The role name used in the document. 111 | :param rawtext: The entire markup snippet, with role. 112 | :param text: The text marked with the role. 113 | :param lineno: The line number where rawtext appears in the input. 114 | :param inliner: The inliner instance that called us. 115 | :param options: Directive options for customization. 116 | :param content: The directive content for customization. 117 | """ 118 | options = options or {} 119 | content = content or [] 120 | # info('user link %r' % text) 121 | ref = "https://www.github.com/" + text 122 | node = nodes.reference(rawtext, text, refuri=ref, **options) 123 | return [node], [] 124 | 125 | 126 | def ghcommit_role(name, rawtext, text, lineno, inliner, options=None, content=None): 127 | """Link to a GitHub commit. 128 | 129 | Returns 2 part tuple containing list of nodes to insert into the 130 | document and a list of system messages. Both are allowed to be 131 | empty. 132 | 133 | :param name: The role name used in the document. 134 | :param rawtext: The entire markup snippet, with role. 135 | :param text: The text marked with the role. 136 | :param lineno: The line number where rawtext appears in the input. 137 | :param inliner: The inliner instance that called us. 138 | :param options: Directive options for customization. 139 | :param content: The directive content for customization. 140 | """ 141 | options = options or {} 142 | content = content or [] 143 | app = inliner.document.settings.env.app 144 | # info('user link %r' % text) 145 | try: 146 | base = app.config.github_project_url 147 | if not base: 148 | raise AttributeError 149 | if not base.endswith("/"): 150 | base += "/" 151 | except AttributeError as err: 152 | raise ValueError( 153 | "github_project_url configuration value is not set (%s)" % str(err) 154 | ) from err 155 | 156 | ref = base + text 157 | node = nodes.reference(rawtext, text[:6], refuri=ref, **options) 158 | return [node], [] 159 | 160 | 161 | def setup(app): 162 | """Install the plugin. 163 | 164 | :param app: Sphinx application context. 165 | """ 166 | info("Initializing GitHub plugin") 167 | app.add_role("ghissue", ghissue_role) 168 | app.add_role("ghpull", ghissue_role) 169 | app.add_role("ghuser", ghuser_role) 170 | app.add_role("ghcommit", ghcommit_role) 171 | app.add_config_value("github_project_url", None, "env") 172 | 173 | return {"parallel_read_safe": True, "parallel_write_safe": True} 174 | -------------------------------------------------------------------------------- /docs/source/using_traitlets.rst: -------------------------------------------------------------------------------- 1 | Using Traitlets 2 | =============== 3 | 4 | In short, traitlets let the user define classes that have 5 | 6 | 1. Attributes (traits) with type checking and dynamically computed 7 | default values 8 | 2. Traits emit change events when attributes are modified 9 | 3. Traitlets perform some validation and allow coercion of new trait 10 | values on assignment. They also allow the user to define custom 11 | validation logic for attributes based on the value of other 12 | attributes. 13 | 14 | Default values, and checking type and value 15 | ------------------------------------------- 16 | 17 | At its most basic, traitlets provides type checking, and dynamic default 18 | value generation of attributes on :class:`traitlets.HasTraits` 19 | subclasses: 20 | 21 | .. code:: python 22 | 23 | from traitlets import HasTraits, Int, Unicode, default 24 | import getpass 25 | 26 | 27 | class Identity(HasTraits): 28 | username = Unicode() 29 | 30 | @default("username") 31 | def _default_username(self): 32 | return getpass.getuser() 33 | 34 | .. code:: python 35 | 36 | class Foo(HasTraits): 37 | bar = Int() 38 | 39 | 40 | foo = Foo(bar="3") # raises a TraitError 41 | 42 | :: 43 | 44 | TraitError: The 'bar' trait of a Foo instance must be an int, 45 | but a value of '3' was specified 46 | 47 | observe 48 | ------- 49 | 50 | Traitlets implement the observer pattern 51 | 52 | .. code:: python 53 | 54 | class Foo(HasTraits): 55 | bar = Int() 56 | baz = Unicode() 57 | 58 | 59 | foo = Foo() 60 | 61 | 62 | def func(change): 63 | print(change["old"]) 64 | print(change["new"]) # as of traitlets 4.3, one should be able to 65 | # write print(change.new) instead 66 | 67 | 68 | foo.observe(func, names=["bar"]) 69 | foo.bar = 1 # prints '0\n 1' 70 | foo.baz = "abc" # prints nothing 71 | 72 | When observers are methods of the class, a decorator syntax can be used. 73 | 74 | .. code:: python 75 | 76 | class Foo(HasTraits): 77 | bar = Int() 78 | baz = Unicode() 79 | 80 | @observe("bar") 81 | def _observe_bar(self, change): 82 | print(change["old"]) 83 | print(change["new"]) 84 | 85 | Validation and Coercion 86 | ----------------------- 87 | 88 | Custom Cross-Validation 89 | ^^^^^^^^^^^^^^^^^^^^^^^ 90 | 91 | Each trait type (``Int``, ``Unicode``, ``Dict`` etc.) may have its own 92 | validation or coercion logic. In addition, we can register custom 93 | cross-validators that may depend on the state of other attributes. 94 | 95 | Basic Example: Validating the Parity of a Trait 96 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 97 | 98 | .. code:: python 99 | 100 | from traitlets import HasTraits, TraitError, Int, Bool, validate 101 | 102 | 103 | class Parity(HasTraits): 104 | data = Int() 105 | parity = Int() 106 | 107 | @validate("data") 108 | def _valid_data(self, proposal): 109 | if proposal["value"] % 2 != self.parity: 110 | raise TraitError("data and parity should be consistent") 111 | return proposal["value"] 112 | 113 | @validate("parity") 114 | def _valid_parity(self, proposal): 115 | parity = proposal["value"] 116 | if parity not in [0, 1]: 117 | raise TraitError("parity should be 0 or 1") 118 | if self.data % 2 != parity: 119 | raise TraitError("data and parity should be consistent") 120 | return proposal["value"] 121 | 122 | 123 | parity_check = Parity(data=2) 124 | 125 | # Changing required parity and value together while holding cross validation 126 | with parity_check.hold_trait_notifications(): 127 | parity_check.data = 1 128 | parity_check.parity = 1 129 | 130 | Notice how all of the examples above return 131 | ``proposal['value']``. Returning a value 132 | is necessary for validation to work 133 | properly, since the new value of the trait will be the 134 | return value of the function decorated by ``@validate``. If this 135 | function does not have any ``return`` statement, then the returned 136 | value will be ``None``, instead of what we wanted (which is ``proposal['value']``). 137 | 138 | However, we recommend that custom cross-validators don't modify the state of 139 | the HasTraits instance. 140 | 141 | Advanced Example: Validating the Schema 142 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 143 | 144 | The ``List`` and ``Dict`` trait types allow the validation of nested 145 | properties. 146 | 147 | .. code:: python 148 | 149 | from traitlets import HasTraits, Dict, Bool, Unicode 150 | 151 | 152 | class Nested(HasTraits): 153 | value = Dict( 154 | per_key_traits={"configuration": Dict(value_trait=Unicode()), "flag": Bool()} 155 | ) 156 | 157 | 158 | n = Nested() 159 | n.value = dict(flag=True, configuration={}) # OK 160 | n.value = dict(flag=True, configuration="") # raises a TraitError. 161 | 162 | 163 | However, for deeply nested properties it might be more appropriate to use an 164 | external validator: 165 | 166 | .. code:: python 167 | 168 | import jsonschema 169 | 170 | value_schema = { 171 | "type": "object", 172 | "properties": { 173 | "price": {"type": "number"}, 174 | "name": {"type": "string"}, 175 | }, 176 | } 177 | 178 | from traitlets import HasTraits, Dict, TraitError, validate, default 179 | 180 | 181 | class Schema(HasTraits): 182 | value = Dict() 183 | 184 | @default("value") 185 | def _default_value(self): 186 | return dict(name="", price=1) 187 | 188 | @validate("value") 189 | def _validate_value(self, proposal): 190 | try: 191 | jsonschema.validate(proposal["value"], value_schema) 192 | except jsonschema.ValidationError as e: 193 | raise TraitError(e) 194 | return proposal["value"] 195 | 196 | 197 | s = Schema() 198 | s.value = dict(name="", price="1") # raises a TraitError 199 | 200 | 201 | Holding Trait Cross-Validation and Notifications 202 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 203 | 204 | Sometimes it may be impossible to transition between valid states for a 205 | ``HasTraits`` instance by changing attributes one by one. The 206 | ``hold_trait_notifications`` context manager can be used to hold the custom 207 | cross validation until the context manager is released. If a validation error 208 | occurs, changes are rolled back to the initial state. 209 | 210 | Custom Events 211 | ------------- 212 | 213 | Finally, trait types can emit other events types than trait changes. This 214 | capability was added so as to enable notifications on change of values in 215 | container classes. The items available in the dictionary passed to the observer 216 | registered with ``observe`` depends on the event type. 217 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest coverage gettext api 23 | 24 | default: html 25 | 26 | help: 27 | @echo "Please use \`make ' where is one of" 28 | @echo " html to make standalone HTML files" 29 | @echo " dirhtml to make HTML files named index.html in directories" 30 | @echo " singlehtml to make a single large HTML file" 31 | @echo " pickle to make pickle files" 32 | @echo " json to make JSON files" 33 | @echo " htmlhelp to make HTML files and a HTML help project" 34 | @echo " qthelp to make HTML files and a qthelp project" 35 | @echo " applehelp to make an Apple Help Book" 36 | @echo " devhelp to make HTML files and a Devhelp project" 37 | @echo " epub to make an epub" 38 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 39 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 40 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 41 | @echo " text to make text files" 42 | @echo " man to make manual pages" 43 | @echo " texinfo to make Texinfo files" 44 | @echo " info to make Texinfo files and run them through makeinfo" 45 | @echo " gettext to make PO message catalogs" 46 | @echo " changes to make an overview of all changed/added/deprecated items" 47 | @echo " xml to make Docutils-native XML files" 48 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 49 | @echo " linkcheck to check all external links for integrity" 50 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 51 | @echo " coverage to run coverage check of the documentation (if enabled)" 52 | 53 | clean: 54 | rm -rf $(BUILDDIR)/* 55 | 56 | html: 57 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 58 | @echo 59 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 60 | 61 | dirhtml: 62 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 63 | @echo 64 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 65 | 66 | singlehtml: 67 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 68 | @echo 69 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 70 | 71 | pickle: 72 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 73 | @echo 74 | @echo "Build finished; now you can process the pickle files." 75 | 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | htmlhelp: 82 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 83 | @echo 84 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 85 | ".hhp project file in $(BUILDDIR)/htmlhelp." 86 | 87 | qthelp: 88 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 89 | @echo 90 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 91 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 92 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/traitlets.qhcp" 93 | @echo "To view the help file:" 94 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/traitlets.qhc" 95 | 96 | applehelp: 97 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 98 | @echo 99 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 100 | @echo "N.B. You won't be able to view it unless you put it in" \ 101 | "~/Library/Documentation/Help or install it in your application" \ 102 | "bundle." 103 | 104 | devhelp: 105 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 106 | @echo 107 | @echo "Build finished." 108 | @echo "To view the help file:" 109 | @echo "# mkdir -p $$HOME/.local/share/devhelp/traitlets" 110 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/traitlets" 111 | @echo "# devhelp" 112 | 113 | epub: 114 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 115 | @echo 116 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 117 | 118 | latex: 119 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 120 | @echo 121 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 122 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 123 | "(use \`make latexpdf' here to do that automatically)." 124 | 125 | latexpdf: 126 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 127 | @echo "Running LaTeX files through pdflatex..." 128 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 129 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 130 | 131 | latexpdfja: 132 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 133 | @echo "Running LaTeX files through platex and dvipdfmx..." 134 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 135 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 136 | 137 | text: 138 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 139 | @echo 140 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 141 | 142 | man: 143 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 144 | @echo 145 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 146 | 147 | texinfo: 148 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 149 | @echo 150 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 151 | @echo "Run \`make' in that directory to run these through makeinfo" \ 152 | "(use \`make info' here to do that automatically)." 153 | 154 | info: 155 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 156 | @echo "Running Texinfo files through makeinfo..." 157 | make -C $(BUILDDIR)/texinfo info 158 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 159 | 160 | gettext: 161 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 162 | @echo 163 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 164 | 165 | changes: 166 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 167 | @echo 168 | @echo "The overview file is in $(BUILDDIR)/changes." 169 | 170 | linkcheck: 171 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 172 | @echo 173 | @echo "Link check complete; look for any errors in the above output " \ 174 | "or in $(BUILDDIR)/linkcheck/output.txt." 175 | 176 | doctest: 177 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 178 | @echo "Testing of doctests in the sources finished, look at the " \ 179 | "results in $(BUILDDIR)/doctest/output.txt." 180 | 181 | coverage: 182 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 183 | @echo "Testing of coverage in the sources finished, look at the " \ 184 | "results in $(BUILDDIR)/coverage/python.txt." 185 | 186 | xml: 187 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 188 | @echo 189 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 190 | 191 | pseudoxml: 192 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 193 | @echo 194 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 195 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Traitlets 2 | 3 | [![Tests](https://github.com/ipython/traitlets/actions/workflows/tests.yml/badge.svg)](https://github.com/ipython/traitlets/actions/workflows/tests.yml) 4 | [![Documentation Status](https://readthedocs.org/projects/traitlets/badge/?version=latest)](https://traitlets.readthedocs.io/en/latest/?badge=latest) 5 | [![Tidelift](https://tidelift.com/subscription/pkg/pypi-traitlets)](https://tidelift.com/badges/package/pypi/traitlets) 6 | 7 | | | | 8 | | ------------- | ------------------------------------ | 9 | | **home** | https://github.com/ipython/traitlets | 10 | | **pypi-repo** | https://pypi.org/project/traitlets/ | 11 | | **docs** | https://traitlets.readthedocs.io/ | 12 | | **license** | Modified BSD License | 13 | 14 | Traitlets is a pure Python library enabling: 15 | 16 | - the enforcement of strong typing for attributes of Python objects 17 | (typed attributes are called _"traits"_); 18 | - dynamically calculated default values; 19 | - automatic validation and coercion of trait attributes when attempting a 20 | change; 21 | - registering for receiving notifications when trait values change; 22 | - reading configuring values from files or from command line 23 | arguments - a distinct layer on top of traitlets, so you may use 24 | traitlets without the configuration machinery. 25 | 26 | Its implementation relies on the [descriptor](https://docs.python.org/howto/descriptor.html) 27 | pattern, and it is a lightweight pure-python alternative of the 28 | [_traits_ library](https://docs.enthought.com/traits/). 29 | 30 | Traitlets powers the configuration system of IPython and Jupyter 31 | and the declarative API of IPython interactive widgets. 32 | 33 | ## Installation 34 | 35 | For a local installation, make sure you have 36 | [pip installed](https://pip.pypa.io/en/stable/installing/) and run: 37 | 38 | ```bash 39 | pip install traitlets 40 | ``` 41 | 42 | For a **development installation**, clone this repository, change into the 43 | `traitlets` root directory, and run pip: 44 | 45 | ```bash 46 | git clone https://github.com/ipython/traitlets.git 47 | cd traitlets 48 | pip install -e . 49 | ``` 50 | 51 | ## Running the tests 52 | 53 | ```bash 54 | pip install "traitlets[test]" 55 | py.test traitlets 56 | ``` 57 | 58 | ## Code Styling 59 | 60 | `traitlets` has adopted automatic code formatting so you shouldn't 61 | need to worry too much about your code style. 62 | As long as your code is valid, 63 | the pre-commit hook should take care of how it should look. 64 | 65 | To install `pre-commit` locally, run the following:: 66 | 67 | ``` 68 | pip install pre-commit 69 | pre-commit install 70 | ``` 71 | 72 | You can invoke the pre-commit hook by hand at any time with:: 73 | 74 | ``` 75 | pre-commit run 76 | ``` 77 | 78 | which should run any autoformatting on your code 79 | and tell you about any errors it couldn't fix automatically. 80 | You may also install [black integration](https://github.com/psf/black#editor-integration) 81 | into your text editor to format code automatically. 82 | 83 | If you have already committed files before setting up the pre-commit 84 | hook with `pre-commit install`, you can fix everything up using 85 | `pre-commit run --all-files`. You need to make the fixing commit 86 | yourself after that. 87 | 88 | Some of the hooks only run on CI by default, but you can invoke them by 89 | running with the `--hook-stage manual` argument. 90 | 91 | ## Usage 92 | 93 | Any class with trait attributes must inherit from `HasTraits`. 94 | For the list of available trait types and their properties, see the 95 | [Trait Types](https://traitlets.readthedocs.io/en/latest/trait_types.html) 96 | section of the documentation. 97 | 98 | ### Dynamic default values 99 | 100 | To calculate a default value dynamically, decorate a method of your class with 101 | `@default({traitname})`. This method will be called on the instance, and 102 | should return the default value. In this example, the `_username_default` 103 | method is decorated with `@default('username')`: 104 | 105 | ```Python 106 | import getpass 107 | from traitlets import HasTraits, Unicode, default 108 | 109 | class Identity(HasTraits): 110 | username = Unicode() 111 | 112 | @default('username') 113 | def _username_default(self): 114 | return getpass.getuser() 115 | ``` 116 | 117 | ### Callbacks when a trait attribute changes 118 | 119 | When a trait changes, an application can follow this trait change with 120 | additional actions. 121 | 122 | To do something when a trait attribute is changed, decorate a method with 123 | [`traitlets.observe()`](https://traitlets.readthedocs.io/en/latest/api.html?highlight=observe#traitlets.observe). 124 | The method will be called with a single argument, a dictionary which contains 125 | an owner, new value, old value, name of the changed trait, and the event type. 126 | 127 | In this example, the `_num_changed` method is decorated with `` @observe(`num`) ``: 128 | 129 | ```Python 130 | from traitlets import HasTraits, Integer, observe 131 | 132 | class TraitletsExample(HasTraits): 133 | num = Integer(5, help="a number").tag(config=True) 134 | 135 | @observe('num') 136 | def _num_changed(self, change): 137 | print("{name} changed from {old} to {new}".format(**change)) 138 | ``` 139 | 140 | and is passed the following dictionary when called: 141 | 142 | ```Python 143 | { 144 | 'owner': object, # The HasTraits instance 145 | 'new': 6, # The new value 146 | 'old': 5, # The old value 147 | 'name': "foo", # The name of the changed trait 148 | 'type': 'change', # The event type of the notification, usually 'change' 149 | } 150 | ``` 151 | 152 | ### Validation and coercion 153 | 154 | Each trait type (`Int`, `Unicode`, `Dict` etc.) may have its own validation or 155 | coercion logic. In addition, we can register custom cross-validators 156 | that may depend on the state of other attributes. For example: 157 | 158 | ```Python 159 | from traitlets import HasTraits, TraitError, Int, Bool, validate 160 | 161 | class Parity(HasTraits): 162 | value = Int() 163 | parity = Int() 164 | 165 | @validate('value') 166 | def _valid_value(self, proposal): 167 | if proposal['value'] % 2 != self.parity: 168 | raise TraitError('value and parity should be consistent') 169 | return proposal['value'] 170 | 171 | @validate('parity') 172 | def _valid_parity(self, proposal): 173 | parity = proposal['value'] 174 | if parity not in [0, 1]: 175 | raise TraitError('parity should be 0 or 1') 176 | if self.value % 2 != parity: 177 | raise TraitError('value and parity should be consistent') 178 | return proposal['value'] 179 | 180 | parity_check = Parity(value=2) 181 | 182 | # Changing required parity and value together while holding cross validation 183 | with parity_check.hold_trait_notifications(): 184 | parity_check.value = 1 185 | parity_check.parity = 1 186 | ``` 187 | 188 | However, we **recommend** that custom cross-validators don't modify the state 189 | of the HasTraits instance. 190 | 191 | ## About the IPython Development Team 192 | 193 | The IPython Development Team is the set of all contributors to the IPython project. 194 | This includes all of the IPython subprojects. 195 | 196 | The core team that coordinates development on GitHub can be found here: 197 | https://github.com/jupyter/. 198 | 199 | ## Our Copyright Policy 200 | 201 | IPython uses a shared copyright model. Each contributor maintains copyright 202 | over their contributions to IPython. But, it is important to note that these 203 | contributions are typically only changes to the repositories. Thus, the IPython 204 | source code, in its entirety is not the copyright of any single person or 205 | institution. Instead, it is the collective copyright of the entire IPython 206 | Development Team. If individual contributors want to maintain a record of what 207 | changes/contributions they have specific copyright on, they should indicate 208 | their copyright in the commit message of the change, when they commit the 209 | change to one of the IPython repositories. 210 | 211 | With this in mind, the following banner should be used in any source code file 212 | to indicate the copyright and license terms: 213 | 214 | ``` 215 | # Copyright (c) IPython Development Team. 216 | # Distributed under the terms of the Modified BSD License. 217 | ``` 218 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% source 10 | set I18NSPHINXOPTS=%SPHINXOPTS% source 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | echo. coverage to run coverage check of the documentation if enabled 41 | goto end 42 | ) 43 | 44 | if "%1" == "clean" ( 45 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 46 | del /q /s %BUILDDIR%\* 47 | goto end 48 | ) 49 | 50 | 51 | REM Check if sphinx-build is available and fallback to Python version if any 52 | %SPHINXBUILD% 2> nul 53 | if errorlevel 9009 goto sphinx_python 54 | goto sphinx_ok 55 | 56 | :sphinx_python 57 | 58 | set SPHINXBUILD=python -m sphinx.__init__ 59 | %SPHINXBUILD% 2> nul 60 | if errorlevel 9009 ( 61 | echo. 62 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 63 | echo.installed, then set the SPHINXBUILD environment variable to point 64 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 65 | echo.may add the Sphinx directory to PATH. 66 | echo. 67 | echo.If you don't have Sphinx installed, grab it from 68 | echo.http://sphinx-doc.org/ 69 | exit /b 1 70 | ) 71 | 72 | :sphinx_ok 73 | 74 | 75 | if "%1" == "html" ( 76 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 77 | if errorlevel 1 exit /b 1 78 | echo. 79 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 80 | goto end 81 | ) 82 | 83 | if "%1" == "dirhtml" ( 84 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 85 | if errorlevel 1 exit /b 1 86 | echo. 87 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 88 | goto end 89 | ) 90 | 91 | if "%1" == "singlehtml" ( 92 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 93 | if errorlevel 1 exit /b 1 94 | echo. 95 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 96 | goto end 97 | ) 98 | 99 | if "%1" == "pickle" ( 100 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 101 | if errorlevel 1 exit /b 1 102 | echo. 103 | echo.Build finished; now you can process the pickle files. 104 | goto end 105 | ) 106 | 107 | if "%1" == "json" ( 108 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 109 | if errorlevel 1 exit /b 1 110 | echo. 111 | echo.Build finished; now you can process the JSON files. 112 | goto end 113 | ) 114 | 115 | if "%1" == "htmlhelp" ( 116 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 117 | if errorlevel 1 exit /b 1 118 | echo. 119 | echo.Build finished; now you can run HTML Help Workshop with the ^ 120 | .hhp project file in %BUILDDIR%/htmlhelp. 121 | goto end 122 | ) 123 | 124 | if "%1" == "qthelp" ( 125 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 129 | .qhcp project file in %BUILDDIR%/qthelp, like this: 130 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\traitlets.qhcp 131 | echo.To view the help file: 132 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\traitlets.ghc 133 | goto end 134 | ) 135 | 136 | if "%1" == "devhelp" ( 137 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 138 | if errorlevel 1 exit /b 1 139 | echo. 140 | echo.Build finished. 141 | goto end 142 | ) 143 | 144 | if "%1" == "epub" ( 145 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 146 | if errorlevel 1 exit /b 1 147 | echo. 148 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 149 | goto end 150 | ) 151 | 152 | if "%1" == "latex" ( 153 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 154 | if errorlevel 1 exit /b 1 155 | echo. 156 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 157 | goto end 158 | ) 159 | 160 | if "%1" == "latexpdf" ( 161 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 162 | cd %BUILDDIR%/latex 163 | make all-pdf 164 | cd %~dp0 165 | echo. 166 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdfja" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf-ja 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "text" ( 181 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 182 | if errorlevel 1 exit /b 1 183 | echo. 184 | echo.Build finished. The text files are in %BUILDDIR%/text. 185 | goto end 186 | ) 187 | 188 | if "%1" == "man" ( 189 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 190 | if errorlevel 1 exit /b 1 191 | echo. 192 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 193 | goto end 194 | ) 195 | 196 | if "%1" == "texinfo" ( 197 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 198 | if errorlevel 1 exit /b 1 199 | echo. 200 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 201 | goto end 202 | ) 203 | 204 | if "%1" == "gettext" ( 205 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 206 | if errorlevel 1 exit /b 1 207 | echo. 208 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 209 | goto end 210 | ) 211 | 212 | if "%1" == "changes" ( 213 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 214 | if errorlevel 1 exit /b 1 215 | echo. 216 | echo.The overview file is in %BUILDDIR%/changes. 217 | goto end 218 | ) 219 | 220 | if "%1" == "linkcheck" ( 221 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 222 | if errorlevel 1 exit /b 1 223 | echo. 224 | echo.Link check complete; look for any errors in the above output ^ 225 | or in %BUILDDIR%/linkcheck/output.txt. 226 | goto end 227 | ) 228 | 229 | if "%1" == "doctest" ( 230 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 231 | if errorlevel 1 exit /b 1 232 | echo. 233 | echo.Testing of doctests in the sources finished, look at the ^ 234 | results in %BUILDDIR%/doctest/output.txt. 235 | goto end 236 | ) 237 | 238 | if "%1" == "coverage" ( 239 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 240 | if errorlevel 1 exit /b 1 241 | echo. 242 | echo.Testing of coverage in the sources finished, look at the ^ 243 | results in %BUILDDIR%/coverage/python.txt. 244 | goto end 245 | ) 246 | 247 | if "%1" == "xml" ( 248 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 249 | if errorlevel 1 exit /b 1 250 | echo. 251 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 252 | goto end 253 | ) 254 | 255 | if "%1" == "pseudoxml" ( 256 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 257 | if errorlevel 1 exit /b 1 258 | echo. 259 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 260 | goto end 261 | ) 262 | 263 | :end 264 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling >=1.5"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "traitlets" 7 | authors = [{name = "IPython Development Team", email = "ipython-dev@python.org"}] 8 | description = "Traitlets Python configuration system" 9 | license = {file = "LICENSE"} 10 | readme = "README.md" 11 | keywords = ["Interactive", "Interpreter", "Shell", "Web"] 12 | classifiers = [ 13 | "Framework :: IPython", 14 | "Framework :: Jupyter", 15 | "Intended Audience :: Developers", 16 | "Intended Audience :: Science/Research", 17 | "Intended Audience :: System Administrators", 18 | "License :: OSI Approved :: BSD License", 19 | "Programming Language :: Python :: 3", 20 | "Programming Language :: Python", 21 | "Typing :: Typed", 22 | ] 23 | requires-python = ">=3.9" 24 | dynamic = ["version"] 25 | 26 | [project.urls] 27 | Homepage = "https://github.com/ipython/traitlets" 28 | Documentation = "https://traitlets.readthedocs.io" 29 | Source = "https://github.com/ipython/traitlets" 30 | Funding = "https://numfocus.org" 31 | Tracker = "https://github.com/ipython/traitlets/issues" 32 | 33 | [project.optional-dependencies] 34 | test = [ 35 | "argcomplete>=3.0.3", 36 | "mypy>=1.7.0", 37 | "pre-commit", 38 | "pytest-mock", 39 | "pytest-mypy-testing", 40 | "pytest>=7.0,<8.2", 41 | ] 42 | docs = [ 43 | "myst-parser", 44 | "pydata-sphinx-theme", 45 | "sphinx" 46 | ] 47 | 48 | [tool.hatch.version] 49 | path = "traitlets/_version.py" 50 | 51 | [tool.hatch.metadata] 52 | allow-direct-references = true 53 | 54 | [tool.hatch.envs.docs] 55 | features = ["docs"] 56 | [tool.hatch.envs.docs.scripts] 57 | build = "make -C docs html SPHINXOPTS='-W'" 58 | 59 | [tool.hatch.envs.test] 60 | features = ["test"] 61 | [tool.hatch.envs.test.scripts] 62 | test = "python -m pytest -vv {args}" 63 | nowarn = "test -W default {args}" 64 | 65 | [tool.hatch.envs.cov] 66 | features = ["test"] 67 | dependencies = ["coverage[toml]", "pytest-cov"] 68 | [tool.hatch.envs.cov.scripts] 69 | test = "python -m pytest -vv --cov traitlets --cov-branch --cov-report term-missing:skip-covered {args}" 70 | nowarn = "test -W default {args}" 71 | 72 | [tool.hatch.envs.typing] 73 | dependencies = ["pre-commit"] 74 | detached = true 75 | [tool.hatch.envs.typing.scripts] 76 | test = "pre-commit run --all-files --hook-stage manual mypy" 77 | 78 | [tool.hatch.envs.lint] 79 | dependencies = ["pre-commit"] 80 | detached = true 81 | [tool.hatch.envs.lint.scripts] 82 | build = [ 83 | "pre-commit run --all-files ruff", 84 | "pre-commit run --all-files ruff-format" 85 | ] 86 | 87 | [tool.mypy] 88 | files = "traitlets" 89 | python_version = "3.9" 90 | strict = true 91 | enable_error_code = ["ignore-without-code", "redundant-expr", "truthy-bool"] 92 | pretty = true 93 | show_error_context = true 94 | warn_unreachable = true 95 | exclude = ["examples/docs/configs", "traitlets/tests/test_typing.py"] 96 | 97 | [tool.pytest.ini_options] 98 | minversion = "6.0" 99 | xfail_strict = true 100 | log_cli_level = "info" 101 | addopts = [ 102 | "-ra", "--durations=10", "--color=yes", "--doctest-modules", 103 | "--showlocals", "--strict-markers", "--strict-config", 104 | "--ignore=examples/docs/configs" 105 | ] 106 | testpaths = [ 107 | "tests", 108 | "examples", 109 | ] 110 | filterwarnings = [ 111 | "ignore", 112 | "ignore:Passing unrecognized arguments", 113 | "ignore:Keyword .* is deprecated", 114 | "ignore:Supporting extra quotes around", 115 | "ignore:DeprecatedApp._config_changed is deprecated in traitlets", 116 | "ignore:A parent of DeprecatedApp._config_changed has adopted", 117 | "ignore:KeyValueConfigLoader is deprecated since Traitlets", 118 | "ignore:Traits should be given as instances", 119 | "ignore:Explicit using of Undefined as", 120 | "ignore:on_trait_change is deprecated", 121 | "ignore:.*use @observe and @unobserve instead", 122 | "ignore:.*for no default is deprecated in traitlets", 123 | "ignore:.*use @validate decorator instead", 124 | "ignore:.*has adopted the new \\(traitlets 4.1\\) @observe\\(change\\) API", 125 | "ignore:.*will be removed in Jinja 3.1.:DeprecationWarning", 126 | "ignore: the imp module is deprecated in favour of importlib", 127 | "ignore:IPython.core.inputsplitter is deprecated since IPython 7 in favor of `IPython.core.inputtransformer2`:DeprecationWarning", 128 | "ignore:pyee.EventEmitter is deprecated and will be removed in a future major version", # you should instead use either pyee.AsyncIOEventEmitter, pyee.TwistedEventEmitter, pyee.ExecutorEventEmitter, pyee.TrioEventEmitter, or pyee.BaseEventEmitter.:DeprecationWarning 129 | "ignore:Your element with mimetype.*", 130 | ] 131 | markers = [ 132 | "network: marks tests which require network connection (nbconvert)", 133 | "integration_tests: notebook mark (notebook)", 134 | ] 135 | 136 | [tool.coverage.report] 137 | exclude_lines = [ 138 | "pragma: no cover", 139 | "def __repr__", 140 | "if self.debug:", 141 | "if settings.DEBUG", 142 | "raise AssertionError", 143 | "raise NotImplementedError", 144 | "if 0:", 145 | "if __name__ == .__main__.:", 146 | "class .*\bProtocol\\):", 147 | "@(abc\\.)?abstractmethod", 148 | ] 149 | 150 | [tool.coverage.run] 151 | relative_files = true 152 | source = ["traitlets"] 153 | 154 | [tool.ruff] 155 | line-length = 100 156 | 157 | [tool.ruff.lint] 158 | extend-select = [ 159 | "B", # flake8-bugbear 160 | "I", # isort 161 | "ARG", # flake8-unused-arguments 162 | "C4", # flake8-comprehensions 163 | # "EM", # flake8-errmsg 164 | "ICN", # flake8-import-conventions 165 | "G", # flake8-logging-format 166 | "PGH", # pygrep-hooks 167 | "PIE", # flake8-pie 168 | "PL", # pylint 169 | # "PTH", # flake8-use-pathlib 170 | "PT", # flake8-pytest-style 171 | "RET", # flake8-return 172 | "RUF", # Ruff-specific 173 | "SIM", # flake8-simplify 174 | "T20", # flake8-print 175 | "UP", # pyupgrade 176 | "YTT", # flake8-2020 177 | "EXE", # flake8-executable 178 | "PYI", # flake8-pyi 179 | "S", # flake8-bandit 180 | ] 181 | ignore = [ 182 | "PLR", # Design related pylint codes 183 | "B027", # Allow non-abstract empty methods in abstract base classes 184 | "S101", # Use of `assert` detected 185 | "E501", # Line too long 186 | "S105", "S106", # Possible hardcoded password 187 | "S110", # S110 `try`-`except`-`pass` detected 188 | "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` 189 | "UP006", # non-pep585-annotation 190 | "UP007", # non-pep604-annotation 191 | "ARG001", "ARG002", # Unused function argument 192 | "RET503", # Missing explicit `return` at the end of function 193 | "RET505", # Unnecessary `else` after `return` statement 194 | "SIM102", # Use a single `if` statement instead of nested `if` statements 195 | "SIM105", # Use `contextlib.suppress(ValueError)` 196 | "SIM108", # Use ternary operator 197 | "SIM114", # Combine `if` branches using logical `or` operator" 198 | "PLW2901", # `for` loop variable `path` overwritten by assignment target 199 | # https://github.com/astral-sh/ruff/issues/8796#issuecomment-1825907715 200 | "PT001", "PT004", "PT005", "PT02", "PT009" 201 | ] 202 | unfixable = [ 203 | # Don't touch noqa lines 204 | "RUF100", 205 | ] 206 | isort.required-imports = ["from __future__ import annotations"] 207 | 208 | [tool.ruff.lint.per-file-ignores] 209 | # B011 Do not call assert False since python -O removes these calls 210 | # F841 local variable 'foo' is assigned to but never used 211 | # C408 Unnecessary `dict` call 212 | # E402 Module level import not at top of file 213 | # T201 `print` found 214 | # B007 Loop control variable `i` not used within the loop body. 215 | # N802 Function name `assertIn` should be lowercase 216 | # F841 Local variable `t` is assigned to but never used 217 | # B018 Found useless expression 218 | # S301 `pickle` and modules that wrap..." 219 | # PGH003 Use specific rule codes when ignoring type issues 220 | "tests/*" = ["B011", "F841", "C408", "E402", "T201", "B007", "N802", "F841", 221 | "B018", "S301", "ARG", "PGH003"] 222 | "traitlets/config/application.py" = ["T201"] 223 | # B003 Assigning to os.environ doesn't clear the environment 224 | "tests/config/*" = ["B003", "B018", "S301"] 225 | # F401 `_version.__version__` imported but unused 226 | # F403 `from .traitlets import *` used; unable to detect undefined names 227 | "traitlets/*__init__.py" = ["F401", "F403"] 228 | "examples/*" = ["T201"] 229 | 230 | [tool.ruff.lint.flake8-pytest-style] 231 | fixture-parentheses = false 232 | mark-parentheses = false 233 | parametrize-names-type = "csv" 234 | -------------------------------------------------------------------------------- /tests/config/test_argcomplete.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tests for argcomplete handling by traitlets.config.application.Application 3 | """ 4 | 5 | # Copyright (c) IPython Development Team. 6 | # Distributed under the terms of the Modified BSD License. 7 | from __future__ import annotations 8 | 9 | import io 10 | import os 11 | import typing as t 12 | 13 | import pytest 14 | 15 | argcomplete = pytest.importorskip("argcomplete") 16 | 17 | from traitlets import Unicode 18 | from traitlets.config.application import Application 19 | from traitlets.config.configurable import Configurable 20 | from traitlets.config.loader import KVArgParseConfigLoader 21 | 22 | 23 | class ArgcompleteApp(Application): 24 | """Override loader to pass through kwargs for argcomplete testing""" 25 | 26 | argcomplete_kwargs: t.Dict[str, t.Any] 27 | 28 | def __init__(self, *args, **kwargs): 29 | # For subcommands, inherit argcomplete_kwargs from parent app 30 | parent = kwargs.get("parent") 31 | super().__init__(*args, **kwargs) 32 | if parent: 33 | argcomplete_kwargs = getattr(parent, "argcomplete_kwargs", None) 34 | if argcomplete_kwargs: 35 | self.argcomplete_kwargs = argcomplete_kwargs 36 | 37 | def _create_loader(self, argv, aliases, flags, classes): 38 | loader = KVArgParseConfigLoader( 39 | argv, aliases, flags, classes=classes, log=self.log, subcommands=self.subcommands 40 | ) 41 | loader._argcomplete_kwargs = self.argcomplete_kwargs # type: ignore[attr-defined] 42 | return loader 43 | 44 | 45 | class SubApp1(ArgcompleteApp): 46 | pass 47 | 48 | 49 | class SubApp2(ArgcompleteApp): 50 | @classmethod 51 | def get_subapp_instance(cls, app: Application) -> Application: 52 | app.clear_instance() # since Application is singleton, need to clear main app 53 | return cls.instance(parent=app) # type: ignore[no-any-return] 54 | 55 | 56 | class MainApp(ArgcompleteApp): 57 | subcommands = { 58 | "subapp1": (SubApp1, "First subapp"), 59 | "subapp2": (SubApp2.get_subapp_instance, "Second subapp"), 60 | } 61 | 62 | 63 | class CustomError(Exception): 64 | """Helper for exit hook for testing argcomplete""" 65 | 66 | @classmethod 67 | def exit(cls, code): 68 | raise cls(str(code)) 69 | 70 | 71 | class TestArgcomplete: 72 | IFS = "\013" 73 | COMP_WORDBREAKS = " \t\n\"'><=;|&(:" 74 | 75 | @pytest.fixture() 76 | def argcomplete_on(self, mocker): 77 | """Mostly borrowed from argcomplete's unit test fixtures 78 | 79 | Set up environment variables to mimic those passed by argcomplete 80 | """ 81 | _old_environ = os.environ 82 | os.environ = os.environ.copy() # type: ignore[assignment] 83 | os.environ["_ARGCOMPLETE"] = "1" 84 | os.environ["_ARC_DEBUG"] = "yes" 85 | os.environ["IFS"] = self.IFS 86 | os.environ["_ARGCOMPLETE_COMP_WORDBREAKS"] = self.COMP_WORDBREAKS 87 | 88 | # argcomplete==2.0.0 always calls fdopen(9, "w") to open a debug stream, 89 | # however this could conflict with file descriptors used by pytest 90 | # and lead to obscure errors. Since we are not looking at debug stream 91 | # in these tests, just mock this fdopen call out. 92 | mocker.patch("os.fdopen") 93 | try: 94 | yield 95 | finally: 96 | os.environ = _old_environ 97 | 98 | def run_completer( 99 | self, 100 | app: ArgcompleteApp, 101 | command: str, 102 | point: t.Union[str, int, None] = None, 103 | **kwargs: t.Any, 104 | ) -> t.List[str]: 105 | """Mostly borrowed from argcomplete's unit tests 106 | 107 | Modified to take an application instead of an ArgumentParser 108 | 109 | Command is the current command being completed and point is the index 110 | into the command where the completion is triggered. 111 | """ 112 | if point is None: 113 | point = str(len(command)) 114 | # Flushing tempfile was leading to CI failures with Bad file descriptor, not sure why. 115 | # Fortunately we can just write to a StringIO instead. 116 | # print("Writing completions to temp file with mode=", write_mode) 117 | # from tempfile import TemporaryFile 118 | # with TemporaryFile(mode=write_mode) as t: 119 | strio = io.StringIO() 120 | os.environ["COMP_LINE"] = command 121 | os.environ["COMP_POINT"] = str(point) 122 | 123 | with pytest.raises(CustomError) as cm: # noqa: PT012 124 | app.argcomplete_kwargs = dict( 125 | output_stream=strio, exit_method=CustomError.exit, **kwargs 126 | ) 127 | app.initialize() 128 | 129 | if str(cm.value) != "0": 130 | raise RuntimeError(f"Unexpected exit code {cm.value}") 131 | out = strio.getvalue() 132 | return out.split(self.IFS) 133 | 134 | def test_complete_simple_app(self, argcomplete_on): 135 | app = ArgcompleteApp() 136 | expected = [ 137 | "--help", 138 | "--debug", 139 | "--show-config", 140 | "--show-config-json", 141 | "--log-level", 142 | "--Application.", 143 | "--ArgcompleteApp.", 144 | ] 145 | assert set(self.run_completer(app, "app --")) == set(expected) 146 | 147 | # completing class traits 148 | assert set(self.run_completer(app, "app --App")) > { 149 | "--Application.show_config", 150 | "--Application.log_level", 151 | "--Application.log_format", 152 | } 153 | 154 | def test_complete_custom_completers(self, argcomplete_on): 155 | app = ArgcompleteApp() 156 | # test pre-defined completers for Bool/Enum 157 | assert set(self.run_completer(app, "app --Application.log_level=")) > {"DEBUG", "INFO"} 158 | assert set(self.run_completer(app, "app --ArgcompleteApp.show_config ")) == { 159 | "0", 160 | "1", 161 | "true", 162 | "false", 163 | } 164 | 165 | # test custom completer and mid-command completions 166 | class CustomCls(Configurable): 167 | val = Unicode().tag( 168 | config=True, argcompleter=argcomplete.completers.ChoicesCompleter(["foo", "bar"]) 169 | ) 170 | 171 | class CustomApp(ArgcompleteApp): 172 | classes = [CustomCls] 173 | aliases = {("v", "val"): "CustomCls.val"} 174 | 175 | app = CustomApp() 176 | assert self.run_completer(app, "app --val ") == ["foo", "bar"] 177 | assert self.run_completer(app, "app --val=") == ["foo", "bar"] 178 | assert self.run_completer(app, "app -v ") == ["foo", "bar"] 179 | assert self.run_completer(app, "app -v=") == ["foo", "bar"] 180 | assert self.run_completer(app, "app --CustomCls.val ") == ["foo", "bar"] 181 | assert self.run_completer(app, "app --CustomCls.val=") == ["foo", "bar"] 182 | completions = self.run_completer(app, "app --val= abc xyz", point=10) 183 | # fixed in argcomplete >= 2.0 to return latter below 184 | assert completions == ["--val=foo", "--val=bar"] or completions == ["foo", "bar"] 185 | assert self.run_completer(app, "app --val --log-level=", point=10) == ["foo", "bar"] 186 | 187 | def test_complete_subcommands(self, argcomplete_on): 188 | app = MainApp() 189 | assert set(self.run_completer(app, "app ")) >= {"subapp1", "subapp2"} 190 | assert set(self.run_completer(app, "app sub")) == {"subapp1", "subapp2"} 191 | assert set(self.run_completer(app, "app subapp1")) == {"subapp1"} 192 | 193 | def test_complete_subcommands_subapp1(self, argcomplete_on): 194 | # subcommand handling modifies _ARGCOMPLETE env var global state, so 195 | # only can test one completion per unit test 196 | app = MainApp() 197 | try: 198 | assert set(self.run_completer(app, "app subapp1 --Sub")) > { 199 | "--SubApp1.show_config", 200 | "--SubApp1.log_level", 201 | "--SubApp1.log_format", 202 | } 203 | finally: 204 | SubApp1.clear_instance() 205 | 206 | def test_complete_subcommands_subapp2(self, argcomplete_on): 207 | app = MainApp() 208 | try: 209 | assert set(self.run_completer(app, "app subapp2 --")) > { 210 | "--Application.", 211 | "--SubApp2.", 212 | } 213 | finally: 214 | SubApp2.clear_instance() 215 | 216 | def test_complete_subcommands_main(self, argcomplete_on): 217 | app = MainApp() 218 | completions = set(self.run_completer(app, "app --")) 219 | assert completions > {"--Application.", "--MainApp."} 220 | assert "--SubApp1." not in completions 221 | assert "--SubApp2." not in completions 222 | -------------------------------------------------------------------------------- /docs/source/migration.rst: -------------------------------------------------------------------------------- 1 | Migration from Traitlets 4.0 to Traitlets 4.1 2 | ============================================= 3 | 4 | Traitlets 4.1 introduces a totally new decorator-based API for 5 | configuring traitlets and a couple of other changes. 6 | 7 | However, it is a backward-compatible release and the deprecated APIs 8 | will be supported for some time. 9 | 10 | Separation of metadata and keyword arguments in ``TraitType`` constructors 11 | -------------------------------------------------------------------------- 12 | 13 | In traitlets 4.0, trait types constructors used all unrecognized keyword 14 | arguments passed to the constructor (like ``sync`` or ``config``) to 15 | populate the ``metadata`` dictionary. 16 | 17 | In trailets 4.1, we deprecated this behavior. The preferred method to 18 | populate the metadata for a trait type instance is to use the new 19 | ``tag`` method. 20 | 21 | .. code:: python 22 | 23 | x = Int(allow_none=True, sync=True) # deprecated 24 | x = Int(allow_none=True).tag(sync=True) # ok 25 | 26 | We also deprecated the ``get_metadata`` method. The metadata of a trait 27 | type instance can directly be accessed via the ``metadata`` attribute. 28 | 29 | Deprecation of ``on_trait_change`` 30 | ---------------------------------- 31 | 32 | The most important change in this release is the deprecation of the 33 | ``on_trait_change`` method. 34 | 35 | Instead, we introduced two methods, ``observe`` and ``unobserve`` to 36 | register and unregister handlers (instead of passing ``remove=True`` to 37 | ``on_trait_change`` for the removal). 38 | 39 | - The ``observe`` method takes one positional argument (the handler), 40 | and two keyword arguments, ``names`` and ``type``, which are used to 41 | filter by notification type or by the names of the observed trait 42 | attribute. The special value ``All`` corresponds to listening to all 43 | the notification types or all notifications from the trait 44 | attributes. The ``names`` argument can be a list of string, a string, 45 | or ``All`` and ``type`` can be a string or ``All``. 46 | 47 | - The observe handler's signature is different from the signature of 48 | on\_trait\_change. It takes a single change dictionary argument, 49 | containing 50 | 51 | .. code:: python 52 | 53 | {"type": ""} 54 | 55 | In the case where ``type`` is the string ``'change'``, the following 56 | additional attributes are provided: 57 | 58 | .. code:: python 59 | 60 | { 61 | "owner": "", 62 | "old": "", 63 | "new": "", 64 | "name": "", 65 | } 66 | 67 | The ``type`` key in the change dictionary is meant to enable protocols 68 | for other notification types. By default, its value is equal to the 69 | ``'change'`` string which corresponds to the change of a trait value. 70 | 71 | **Example:** 72 | 73 | .. code:: python 74 | 75 | from traitlets import HasTraits, Int, Unicode 76 | 77 | 78 | class Foo(HasTraits): 79 | bar = Int() 80 | baz = Unicode() 81 | 82 | 83 | def handle_change(change): 84 | print("{name} changed from {old} to {new}".format(**change)) 85 | 86 | 87 | foo = Foo() 88 | foo.observe(handle_change, names="bar") 89 | 90 | The new ``@observe`` decorator 91 | ------------------------------ 92 | 93 | The use of the magic methods ``_{trait}_changed`` as change handlers is 94 | deprecated, in favor of a new ``@observe`` method decorator. 95 | 96 | The ``@observe`` method decorator takes the names of traits to be observed as positional arguments and 97 | has a ``type`` keyword-only argument (defaulting to ``'change'``) to filter 98 | by notification type. 99 | 100 | **Example:** 101 | 102 | .. code:: python 103 | 104 | class Foo(HasTraits): 105 | bar = Int() 106 | baz = EnventfulContainer() # hypothetical trait type emitting 107 | # other notifications types 108 | 109 | @observe("bar") # 'change' notifications for `bar` 110 | def handler_bar(self, change): 111 | pass 112 | 113 | @observe("baz ", type="element_change") # 'element_change' notifications for `baz` 114 | def handler_baz(self, change): 115 | pass 116 | 117 | @observe("bar", "baz", type=All) # all notifications for `bar` and `baz` 118 | def handler_all(self, change): 119 | pass 120 | 121 | dynamic defaults generation with decorators 122 | ------------------------------------------- 123 | 124 | The use of the magic methods ``_{trait}_default`` for dynamic default 125 | generation is not deprecated, but a new ``@default`` method decorator 126 | is added. 127 | 128 | **Example:** 129 | 130 | Default generators should only be called if they are registered in 131 | subclasses of ``trait.this_type``. 132 | 133 | .. code:: python 134 | 135 | from traitlets import HasTraits, Int, Float, default 136 | 137 | 138 | class A(HasTraits): 139 | bar = Int() 140 | 141 | @default("bar") 142 | def get_bar_default(self): 143 | return 11 144 | 145 | 146 | class B(A): 147 | bar = Float() # This ignores the default generator 148 | # defined in the base class A 149 | 150 | 151 | class C(B): 152 | @default("bar") 153 | def some_other_default(self): # This should not be ignored since 154 | return 3.0 # it is defined in a class derived 155 | # from B.a.this_class. 156 | 157 | Deprecation of magic method for cross-validation 158 | ------------------------------------------------ 159 | 160 | ``traitlets`` enables custom cross validation between the different 161 | attributes of a ``HasTraits`` instance. For example, a slider value 162 | should remain bounded by the ``min`` and ``max`` attribute. This 163 | validation occurs before the trait notification fires. 164 | 165 | The use of the magic methods ``_{name}_validate`` for custom 166 | cross-validation is deprecated, in favor of a new ``@validate`` method 167 | decorator. 168 | 169 | The method decorated with the ``@validate`` decorator take a single 170 | ``proposal`` dictionary 171 | 172 | .. code:: python 173 | 174 | { 175 | "trait": "", 176 | "value": "", 177 | "owner": "", 178 | } 179 | 180 | Custom validators may raise ``TraitError`` exceptions in case of invalid 181 | proposal, and should return the value that will be eventually assigned. 182 | 183 | **Example:** 184 | 185 | .. code:: python 186 | 187 | from traitlets import HasTraits, TraitError, Int, Bool, validate 188 | 189 | 190 | class Parity(HasTraits): 191 | value = Int() 192 | parity = Int() 193 | 194 | @validate("value") 195 | def _valid_value(self, proposal): 196 | if proposal["value"] % 2 != self.parity: 197 | raise TraitError("value and parity should be consistent") 198 | return proposal["value"] 199 | 200 | @validate("parity") 201 | def _valid_parity(self, proposal): 202 | parity = proposal["value"] 203 | if parity not in [0, 1]: 204 | raise TraitError("parity should be 0 or 1") 205 | if self.value % 2 != parity: 206 | raise TraitError("value and parity should be consistent") 207 | return proposal["value"] 208 | 209 | 210 | parity_check = Parity(value=2) 211 | 212 | # Changing required parity and value together while holding cross validation 213 | with parity_check.hold_trait_notifications(): 214 | parity_check.value = 1 215 | parity_check.parity = 1 216 | 217 | The presence of the ``owner`` key in the proposal dictionary enable the 218 | use of other attributes of the object in the cross validation logic. 219 | However, we recommend that the custom cross validator don't modify the 220 | other attributes of the object but only coerce the proposed value. 221 | 222 | Backward-compatible upgrades 223 | ---------------------------- 224 | 225 | One challenge in adoption of a changing API is how to adopt the new API 226 | while maintaining backward compatibility for subclasses, 227 | as event listeners methods are *de facto* public APIs. 228 | 229 | Take for instance the following class: 230 | 231 | .. code:: python 232 | 233 | from traitlets import HasTraits, Unicode 234 | 235 | 236 | class Parent(HasTraits): 237 | prefix = Unicode() 238 | path = Unicode() 239 | 240 | def _path_changed(self, name, old, new): 241 | self.prefix = os.path.dirname(new) 242 | 243 | And you know another package has the subclass: 244 | 245 | .. code:: python 246 | 247 | from parent import Parent 248 | 249 | 250 | class Child(Parent): 251 | def _path_changed(self, name, old, new): 252 | super()._path_changed(name, old, new) 253 | if not os.path.exists(new): 254 | os.makedirs(new) 255 | 256 | If the parent package wants to upgrade without breaking Child, 257 | it needs to preserve the signature of ``_path_changed``. 258 | For this, we have provided an ``@observe_compat`` decorator, 259 | which automatically shims the deprecated signature into the new signature: 260 | 261 | .. code:: python 262 | 263 | from traitlets import HasTraits, Unicode, observe, observe_compat 264 | 265 | 266 | class Parent(HasTraits): 267 | prefix = Unicode() 268 | path = Unicode() 269 | 270 | @observe("path") 271 | @observe_compat # <- this allows super()._path_changed in subclasses to work with the old signature. 272 | def _path_changed(self, change): 273 | self.prefix = os.path.dirname(change["value"]) 274 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | A. J. Holyoake ajholyoake 2 | Aaron Culich Aaron Culich 3 | Aron Ahmadia ahmadia 4 | Benjamin Ragan-Kelley 5 | Benjamin Ragan-Kelley Min RK 6 | Benjamin Ragan-Kelley MinRK 7 | Barry Wark Barry Wark 8 | Ben Edwards Ben Edwards 9 | Bradley M. Froehle Bradley M. Froehle 10 | Bradley M. Froehle Bradley Froehle 11 | Brandon Parsons Brandon Parsons 12 | Brian E. Granger Brian Granger 13 | Brian E. Granger Brian Granger <> 14 | Brian E. Granger bgranger <> 15 | Brian E. Granger bgranger 16 | Christoph Gohlke cgohlke 17 | Cyrille Rossant rossant 18 | Damián Avila damianavila 19 | Damián Avila damianavila 20 | Damon Allen damontallen 21 | Darren Dale darren.dale <> 22 | Darren Dale Darren Dale <> 23 | Dav Clark Dav Clark <> 24 | Dav Clark Dav Clark 25 | David Hirschfeld dhirschfeld 26 | David P. Sanders David P. Sanders 27 | David Warde-Farley David Warde-Farley <> 28 | Doug Blank Doug Blank 29 | Eugene Van den Bulke Eugene Van den Bulke 30 | Evan Patterson 31 | Evan Patterson 32 | Evan Patterson 33 | Evan Patterson 34 | Evan Patterson epatters 35 | Evan Patterson epatters 36 | Ernie French Ernie French 37 | Ernie French ernie french 38 | Ernie French ernop 39 | Fernando Perez 40 | Fernando Perez Fernando Perez 41 | Fernando Perez fperez <> 42 | Fernando Perez fptest <> 43 | Fernando Perez fptest1 <> 44 | Fernando Perez Fernando Perez 45 | Fernando Perez Fernando Perez <> 46 | Fernando Perez Fernando Perez 47 | Frank Murphy Frank Murphy 48 | Gabriel Becker gmbecker 49 | Gael Varoquaux gael.varoquaux <> 50 | Gael Varoquaux gvaroquaux 51 | Gael Varoquaux Gael Varoquaux <> 52 | Ingolf Becker watercrossing 53 | Jake Vanderplas Jake Vanderplas 54 | Jakob Gager jakobgager 55 | Jakob Gager jakobgager 56 | Jakob Gager jakobgager 57 | Jason Grout 58 | Jason Grout 59 | Jason Gors jason gors 60 | Jason Gors jgors 61 | Jens Hedegaard Nielsen Jens Hedegaard Nielsen 62 | Jens Hedegaard Nielsen Jens H Nielsen 63 | Jens Hedegaard Nielsen Jens H. Nielsen 64 | Jez Ng Jez Ng 65 | Jonathan Frederic Jonathan Frederic 66 | Jonathan Frederic Jonathan Frederic 67 | Jonathan Frederic Jonathan Frederic 68 | Jonathan Frederic jon 69 | Jonathan Frederic U-Jon-PC\Jon 70 | Jonathan March Jonathan March 71 | Jonathan March jdmarch 72 | Jörgen Stenarson Jörgen Stenarson 73 | Jörgen Stenarson Jorgen Stenarson 74 | Jörgen Stenarson Jorgen Stenarson <> 75 | Jörgen Stenarson jstenar 76 | Jörgen Stenarson jstenar <> 77 | Jörgen Stenarson Jörgen Stenarson 78 | Juergen Hasch juhasch 79 | Juergen Hasch juhasch 80 | Julia Evans Julia Evans 81 | Kester Tong KesterTong 82 | Kyle Kelley Kyle Kelley 83 | Kyle Kelley rgbkrk 84 | Laurent Dufréchou 85 | Laurent Dufréchou 86 | Laurent Dufréchou laurent dufrechou <> 87 | Laurent Dufréchou laurent.dufrechou <> 88 | Laurent Dufréchou Laurent Dufrechou <> 89 | Laurent Dufréchou laurent.dufrechou@gmail.com <> 90 | Laurent Dufréchou ldufrechou 91 | Lorena Pantano Lorena 92 | Luis Pedro Coelho Luis Pedro Coelho 93 | Marc Molla marcmolla 94 | Martín Gaitán Martín Gaitán 95 | Matthias Bussonnier Matthias BUSSONNIER 96 | Matthias Bussonnier Bussonnier Matthias 97 | Matthias Bussonnier Matthias BUSSONNIER 98 | Matthias Bussonnier Matthias Bussonnier 99 | Michael Droettboom Michael Droettboom 100 | Nicholas Bollweg Nicholas Bollweg (Nick) 101 | Nicolas Rougier 102 | Nikolay Koldunov Nikolay Koldunov 103 | Omar Andrés Zapata Mesa Omar Andres Zapata Mesa 104 | Omar Andrés Zapata Mesa Omar Andres Zapata Mesa 105 | Pankaj Pandey Pankaj Pandey 106 | Pascal Schetelat pascal-schetelat 107 | Paul Ivanov Paul Ivanov 108 | Pauli Virtanen Pauli Virtanen <> 109 | Pauli Virtanen Pauli Virtanen 110 | Pierre Gerold Pierre Gerold 111 | Pietro Berkes Pietro Berkes 112 | Piti Ongmongkolkul piti118 113 | Prabhu Ramachandran Prabhu Ramachandran <> 114 | Puneeth Chaganti Puneeth Chaganti 115 | Robert Kern rkern <> 116 | Robert Kern Robert Kern 117 | Robert Kern Robert Kern 118 | Robert Kern Robert Kern <> 119 | Robert Marchman Robert Marchman 120 | Satrajit Ghosh Satrajit Ghosh 121 | Satrajit Ghosh Satrajit Ghosh 122 | Scott Sanderson Scott Sanderson 123 | smithj1 smithj1 124 | smithj1 smithj1 125 | Steven Johnson stevenJohnson 126 | Steven Silvester blink1073 127 | S. Weber s8weber 128 | Stefan van der Walt Stefan van der Walt 129 | Silvia Vinyes Silvia 130 | Silvia Vinyes silviav12 131 | Sylvain Corlay 132 | Sylvain Corlay sylvain.corlay 133 | Ted Drain TD22057 134 | Théophile Studer Théophile Studer 135 | Thomas Kluyver Thomas 136 | Thomas Spura Thomas Spura 137 | Timo Paulssen timo 138 | vds vds2212 139 | vds vds 140 | Ville M. Vainio 141 | Ville M. Vainio ville 142 | Ville M. Vainio ville 143 | Ville M. Vainio vivainio <> 144 | Ville M. Vainio Ville M. Vainio 145 | Ville M. Vainio Ville M. Vainio 146 | Walter Doerwald walter.doerwald <> 147 | Walter Doerwald Walter Doerwald <> 148 | W. Trevor King W. Trevor King 149 | Yoval P. y-p 150 | -------------------------------------------------------------------------------- /traitlets/config/argcomplete_config.py: -------------------------------------------------------------------------------- 1 | """Helper utilities for integrating argcomplete with traitlets""" 2 | 3 | # Copyright (c) IPython Development Team. 4 | # Distributed under the terms of the Modified BSD License. 5 | from __future__ import annotations 6 | 7 | import argparse 8 | import os 9 | import typing as t 10 | 11 | try: 12 | import argcomplete 13 | from argcomplete import CompletionFinder # type:ignore[attr-defined] 14 | except ImportError: 15 | # This module and its utility methods are written to not crash even 16 | # if argcomplete is not installed. 17 | class StubModule: 18 | def __getattr__(self, attr: str) -> t.Any: 19 | if not attr.startswith("__"): 20 | raise ModuleNotFoundError("No module named 'argcomplete'") 21 | raise AttributeError(f"argcomplete stub module has no attribute '{attr}'") 22 | 23 | argcomplete = StubModule() # type:ignore[assignment] 24 | CompletionFinder = object # type:ignore[assignment, misc] 25 | 26 | 27 | def get_argcomplete_cwords() -> t.Optional[t.List[str]]: 28 | """Get current words prior to completion point 29 | 30 | This is normally done in the `argcomplete.CompletionFinder` constructor, 31 | but is exposed here to allow `traitlets` to follow dynamic code-paths such 32 | as determining whether to evaluate a subcommand. 33 | """ 34 | if "_ARGCOMPLETE" not in os.environ: 35 | return None 36 | 37 | comp_line = os.environ["COMP_LINE"] 38 | comp_point = int(os.environ["COMP_POINT"]) 39 | # argcomplete.debug("splitting COMP_LINE for:", comp_line, comp_point) 40 | comp_words: t.List[str] 41 | try: 42 | ( 43 | cword_prequote, 44 | cword_prefix, 45 | cword_suffix, 46 | comp_words, 47 | last_wordbreak_pos, 48 | ) = argcomplete.split_line(comp_line, comp_point) # type:ignore[attr-defined,no-untyped-call] 49 | except ModuleNotFoundError: 50 | return None 51 | 52 | # _ARGCOMPLETE is set by the shell script to tell us where comp_words 53 | # should start, based on what we're completing. 54 | # 1: