Repository: omni-us/jsonargparse

Issue:
Add hooks for before/after instantiating objects
<!-- If you like this project, please ⭐ star it https://github.com/omni-us/jsonargparse/ -->

## 🚀 Feature request

Add methods `jsonargparse.ArgumentParser` that let the user specify custom code that can be executed before / after an object is instantiated.


### Motivation

I'm working on porting the ML system I'm working on to LightningCLI with jsonargparse.

Some of the design goals of this system are:
* Remove magic numbers
* Maintain a ledger of how data is derived
* Remove the need for redundant specifications. 

So far jsonargparse is helping a lot and I'm very impressed with the library. It's by far the best argparse alternative I've come across. However, I'm having an issue slotting it into my system, because I require that my data module introspects my underlying dataset and passes relevant parameters to the model. Unfortunately these are not available at initialization time. I have a MWE illustrating the issue: 

```python
import jsonargparse
import pathlib


class Data:
    def __init__(self, fpath=None):
        self.fpath = pathlib.Path(fpath)
        self.content = None

    def setup(self):
        self.content = self.fpath.read_text()
        return self


class Model:
    def __init__(self, content=None, param1=None):
        assert content is not None
        self.content = content


def main():
    parser = jsonargparse.ArgumentParser()
    parser.add_class_arguments(Data, nested_key='data', fail_untyped=False)
    parser.add_class_arguments(Model, nested_key='model', fail_untyped=False)

    parser.link_arguments('data.content', 'model.content', apply_on='instantiate')

    foo_fpath = pathlib.Path('foo.txt')
    foo_fpath.write_text('content of a file')

    config = parser.parse_args(args=['--data.fpath', str(foo_fpath)])
    instances = parser.instantiate_classes(config)


if __name__ == '__main__':
    main()
```

In the above example, the model cannot be instantiated because the content of the file hasn't been read by the Data class yet.

### Pitch

Perhaps adding methods like: 

* `parser.add_before_instantiate(key, fn): "key specifies the argument, and fn is passed the key, cls, and init_args"`
* `parser.add_after_instantiate(key, fn): "key specifies the argument, and fn is passed the instantiated object"`

But idk if these are the best options. This could also be done by adding `before_instantiate` and `after_instantiate` arguments to `add_class_arguments`. Or perhaps the before / after is shirked entirely and `instantiate` could be given as a function where the user is responsible for actually creating the object. I'm not super concerned about the details, I just want a way to control the creation of objects with more fidelity than is currently possible.

I would like to be able to do something like:

```python
def main_ideal():
    parser = jsonargparse.ArgumentParser()
    parser.add_class_arguments(Data, nested_key='data', fail_untyped=False)
    parser.add_class_arguments(Model, nested_key='model', fail_untyped=False)

    # Proposed new feature
    parser.add_after_instantiate('data', lambda obj: obj.setup())

    parser.link_arguments('data.content', 'model.content', apply_on='instantiate')

    foo_fpath = pathlib.Path('foo.txt')
    foo_fpath.write_text('content of a file')

    config = parser.parse_args(args=['--data.fpath', str(foo_fpath)])
    instances = parser.instantiate_classes(config)
```

Where I can control some pre/post processing step after instantiation happens. That way, in this instance, I can ensure that the correct value is populated in data before link_arguments connects data.content to model.content.

### Alternatives

The natural question is then: why don't I just call `setup()` in `Data.__init__`? The reason is that jsonargparse may not be the only object that needs to use `Data`. And in general I think it's a good design practice to keep as much work out of `__init__` methods as possible (it makes debugging objects easier if you can always make an instance). 

But what about a wrapper class that only jsonargparse gets to see? Well, now we are adding more boilerplate that jsonargparse is designed to avoid.

There is an alternative that I'm currently using, although I really don't like it. I'm effectively hacking `link_arguments` to do something similar enough to what I want. Namely:

```python
    parser = jsonargparse.ArgumentParser()
    parser.add_class_arguments(Data, nested_key='data', fail_untyped=False)
    parser.add_class_arguments(Model, nested_key='model', fail_untyped=False)

    parser.link_arguments('data', 'model.content', apply_on='instantiate', compute_fn=lambda data: data.setup().content)

    foo_fpath = pathlib.Path('foo.txt')
    foo_fpath.write_text('content of a file')

    config = parser.parse_args(args=['--data.fpath', str(foo_fpath)])

    instances = parser.instantiate_classes(config)
    model = instances.model
    print(f'model.content={model.content}')
```

This abuses link arguments to get an instance of "data" before an instance of "model" is created and then applies the custom logic. 

I think this is a clear anti-pattern, but unfortunately I don't see a viable alternative to it. 

### Discussion

I'm open to being the person who implements this PR, but I'd like to get feedback on the issue first. I'm pretty sure I'm not missing anything existing in jsonarparse that could solve my issue, but if I am, I'd love to be pointed to it and just close the issue. However, if this feature is not supported and deemed useful enough to add support for it, I think there should be discussion about how it is implemented. 

Adding two methods  `add_after_instantiate` and `add_before_instantiate` seems like it adds too much clutter to the top-level API. I'm wondering if this should be implemented as a more general hook system: `e.g. add_hook(hook_name, hook_fn)` where the first hooks added are for the instantiate cases I've mentioned.

Although I'd accept adding hook args to `add_class_arguments` as a solution to my issue, it isn't ideal in my case because I'd have to modify LightningCLI to gain access to that feature. Not the end of the world, but I think a method attached to the parser itself that lets you add these hooks would be more valuable.

The other option I'd be completely happy with is an `add_instantiator` that allows the allows the user to override exactly how instantiation happens for an object.

### Summary

I'd like to customize instantiated objects either before and/or after they are created. Is this in the scope of jsonargparse? What are the thoughts of other users / maintainers?

Discussion:
sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=326)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326&metric=new_duplicated_lines_density&view=list)


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/omni-us/jsonargparse/pull/326?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) Report
Patch coverage: **`100.00`**% and project coverage change: **`-0.24`** :warning:
> Comparison is base [(`453a961`)](https://app.codecov.io/gh/omni-us/jsonargparse/commit/453a96149268e35ae13f350a04c1200efb72480e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) 100.00% compared to head [(`30ea932`)](https://app.codecov.io/gh/omni-us/jsonargparse/pull/326?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) 99.76%.

<details><summary>Additional details and impacted files</summary>


```diff
@@             Coverage Diff             @@
##              main     #326      +/-   ##
===========================================
- Coverage   100.00%   99.76%   -0.24%     
===========================================
  Files           21       21              
  Lines         5518     5524       +6     
===========================================
- Hits          5518     5511       -7     
- Misses           0       13      +13     
```

| Flag | Coverage Δ | |
|---|---|---|
| py3.10 | `86.56% <100.00%> (+0.01%)` | :arrow_up: |
| py3.10_all | `98.37% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.10_types | `98.37% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.11 | `86.54% <100.00%> (+0.01%)` | :arrow_up: |
| py3.11_all | `98.35% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.11_types | `98.35% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.6 | `?` | |
| py3.6_all | `?` | |
| py3.7 | `?` | |
| py3.7_all | `?` | |
| py3.7_types | `?` | |
| py3.8 | `?` | |
| py3.8_all | `?` | |
| py3.8_types | `?` | |
| py3.9 | `88.07% <100.00%> (+0.01%)` | :arrow_up: |
| py3.9_all | `99.54% <100.00%> (+<0.01%)` | :arrow_up: |
| py3.9_types | `99.54% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#carryforward-flags-in-the-pull-request-comment) to find out more.

| [Impacted Files](https://app.codecov.io/gh/omni-us/jsonargparse/pull/326?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us) | Coverage Δ | |
|---|---|---|
| [jsonargparse/\_actions.py](https://app.codecov.io/gh/omni-us/jsonargparse/pull/326?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL19hY3Rpb25zLnB5) | `100.00% <100.00%> (ø)` | |
| [jsonargparse/\_common.py](https://app.codecov.io/gh/omni-us/jsonargparse/pull/326?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL19jb21tb24ucHk=) | `100.00% <100.00%> (ø)` | |
| [jsonargparse/\_core.py](https://app.codecov.io/gh/omni-us/jsonargparse/pull/326?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL19jb3JlLnB5) | `100.00% <100.00%> (ø)` | |
| [jsonargparse/\_signatures.py](https://app.codecov.io/gh/omni-us/jsonargparse/pull/326?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL19zaWduYXR1cmVzLnB5) | `100.00% <100.00%> (ø)` | |
| [jsonargparse/\_typehints.py](https://app.codecov.io/gh/omni-us/jsonargparse/pull/326?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us#diff-anNvbmFyZ3BhcnNlL190eXBlaGludHMucHk=) | `100.00% <100.00%> (ø)` | |

... and [4 files with indirect coverage changes](https://app.codecov.io/gh/omni-us/jsonargparse/pull/326/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us)


</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/omni-us/jsonargparse/pull/326?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=omni-us).

sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=326)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=326)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326&metric=new_duplicated_lines_density&view=list)


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!&nbsp; &nbsp; [![Quality Gate passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/passed-16px.png 'Quality Gate passed')](https://sonarcloud.io/dashboard?id=omni-us_jsonargparse&pullRequest=326)

[![Bug](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug-16px.png 'Bug')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=BUG)  
[![Vulnerability](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability-16px.png 'Vulnerability')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=VULNERABILITY)  
[![Security Hotspot](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot-16px.png 'Security Hotspot')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=SECURITY_HOTSPOT)  
[![Code Smell](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell-16px.png 'Code Smell')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL) [![A](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A-16px.png 'A')](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=326&resolved=false&types=CODE_SMELL)

[![No Coverage information](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo-16px.png 'No Coverage information')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326) No Coverage information  
[![0.0%](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/3-16px.png '0.0%')](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=326&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 0dac530..5d7a8f2 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -26,6 +26,9 @@ Added
   <https://github.com/omni-us/jsonargparse/issues/337#issuecomment-1665055459>`__).
 - Improved resolving of nested forward references in types.
 - The ``ext_vars`` for an ``ActionJsonnet`` argument can now have a default.
+- New method ``ArgumentParser.add_instantiator`` that enables developers to
+  implement custom instantiation (`#170
+  <https://github.com/omni-us/jsonargparse/issues/170>`__).
 
 Deprecated
 ^^^^^^^^^^
diff --git a/jsonargparse/_actions.py b/jsonargparse/_actions.py
index 3210335..977f87b 100644
--- a/jsonargparse/_actions.py
+++ b/jsonargparse/_actions.py
@@ -9,7 +9,7 @@ from contextlib import contextmanager
 from contextvars import ContextVar
 from typing import Any, Dict, List, Optional, Tuple, Type, Union
 
-from ._common import is_subclass, parser_context
+from ._common import get_class_instantiator, is_subclass, parser_context
 from ._loaders_dumpers import get_loader_exceptions, load_value
 from ._namespace import Namespace, split_key, split_key_root
 from ._optionals import FilesCompleterMethod, get_config_read_mode
@@ -324,7 +324,8 @@ class _ActionConfigLoad(Action):
         return self._load_config(value, parser)
 
     def instantiate_classes(self, value):
-        return self.basetype(**value)
+        instantiator_fn = get_class_instantiator()
+        return instantiator_fn(self.basetype, **value)
 
 
 class _ActionHelpClassPath(Action):
diff --git a/jsonargparse/_common.py b/jsonargparse/_common.py
index 2b9a48e..348f523 100644
--- a/jsonargparse/_common.py
+++ b/jsonargparse/_common.py
@@ -1,17 +1,36 @@
 import dataclasses
 import inspect
+import sys
 from contextlib import contextmanager
 from contextvars import ContextVar
-from typing import Optional, Union
+from typing import Dict, Optional, Tuple, Type, TypeVar, Union
 
 from ._namespace import Namespace
 from ._type_checking import ArgumentParser
 
+ClassType = TypeVar("ClassType")
+
+if sys.version_info < (3, 8):
+    from typing import Callable
+
+    InstantiatorCallable = Callable[..., ClassType]
+else:
+    from typing import Protocol
+
+    class InstantiatorCallable(Protocol):
+        def __call__(self, class_type: Type[ClassType], *args, **kwargs) -> ClassType:
+            pass  # pragma: no cover
+
+
+InstantiatorsDictType = Dict[Tuple[type, bool], InstantiatorCallable]
+
+
 parent_parser: ContextVar["ArgumentParser"] = ContextVar("parent_parser")
 parser_capture: ContextVar[bool] = ContextVar("parser_capture", default=False)
 defaults_cache: ContextVar[Optional[Namespace]] = ContextVar("defaults_cache", default=None)
 lenient_check: ContextVar[Union[bool, str]] = ContextVar("lenient_check", default=False)
 load_value_mode: ContextVar[Optional[str]] = ContextVar("load_value_mode", default=None)
+class_instantiators: ContextVar[Optional[InstantiatorsDictType]] = ContextVar("class_instantiators")
 
 
 parser_context_vars = dict(
@@ -20,6 +39,7 @@ parser_context_vars = dict(
     defaults_cache=defaults_cache,
     lenient_check=lenient_check,
     load_value_mode=load_value_mode,
+    class_instantiators=class_instantiators,
 )
 
 
@@ -70,3 +90,25 @@ def is_dataclass_like(cls) -> bool:
         if attrs.has(cls):
             return True
     return all_dataclasses
+
+
+def default_class_instantiator(class_type: Type[ClassType], *args, **kwargs) -> ClassType:
+    return class_type(*args, **kwargs)
+
+
+class ClassInstantiator:
+    def __init__(self, instantiators: InstantiatorsDictType) -> None:
+        self.instantiators = instantiators
+
+    def __call__(self, class_type: Type[ClassType], *args, **kwargs) -> ClassType:
+        for (cls, subclasses), instantiator in self.instantiators.items():
+            if class_type is cls or (subclasses and is_subclass(class_type, cls)):
+                return instantiator(class_type, *args, **kwargs)
+        return default_class_instantiator(class_type, *args, **kwargs)
+
+
+def get_class_instantiator() -> InstantiatorCallable:
+    instantiators = class_instantiators.get()
+    if not instantiators:
+        return default_class_instantiator
+    return ClassInstantiator(instantiators)
diff --git a/jsonargparse/_core.py b/jsonargparse/_core.py
index 055a83f..014f056 100644
--- a/jsonargparse/_core.py
+++ b/jsonargparse/_core.py
@@ -37,7 +37,13 @@ from ._actions import (
     parent_parsers,
     previous_config,
 )
-from ._common import is_dataclass_like, lenient_check, parser_context
+from ._common import (
+    InstantiatorCallable,
+    InstantiatorsDictType,
+    is_dataclass_like,
+    lenient_check,
+    parser_context,
+)
 from ._deprecated import ParserDeprecations
 from ._formatters import DefaultHelpFormatter, empty_help, get_env_var
 from ._jsonnet import ActionJsonnet
@@ -73,6 +79,7 @@ from ._parameter_resolvers import UnknownDefault
 from ._signatures import SignatureArguments
 from ._typehints import ActionTypeHint, is_subclass_spec
 from ._util import (
+    ClassType,
     Path,
     argument_error,
     change_to_path_dir,
@@ -176,6 +183,7 @@ class ArgumentParser(ParserDeprecations, ActionsContainer, ArgumentLinking, argp
     formatter_class: Type[DefaultHelpFormatter]
     groups: Optional[Dict[str, "_ArgumentGroup"]] = None
     _subcommands_action: Optional[_ActionSubCommands] = None
+    _instantiators: Optional[InstantiatorsDictType] = None
 
     def __init__(
         self,
@@ -1073,6 +1081,45 @@ class ArgumentParser(ParserDeprecations, ActionsContainer, ArgumentLinking, argp
                 message = prefix + message
             raise type(ex)(message) from ex
 
+    def add_instantiator(
+        self,
+        instantiator: InstantiatorCallable,
+        class_type: Type[ClassType],
+        subclasses: bool = True,
+        prepend: bool = False,
+    ) -> None:
+        """Adds a custom instantiator for a class type. Used by ``instantiate_classes``.
+
+        Instantiator functions are expected to have as signature ``(class_type:
+        Type[ClassType], *args, **kwargs) -> ClassType``.
+
+        For reference, the default instantiator is ``return class_type(*args,
+        **kwargs)``.
+
+        Args:
+            instantiator: Function that instantiates a class.
+            class_type: The class type to instantiate.
+            subclasses: Whether to instantiate subclasses of ``class_type``.
+            prepend: Whether to prepend the instantiator to the existing instantiators.
+        """
+        if self._instantiators is None:
+            self._instantiators = {}
+        key = (class_type, subclasses)
+        instantiators = {k: v for k, v in self._instantiators.items() if k != key}
+        if prepend:
+            self._instantiators = {key: instantiator, **instantiators}
+        else:
+            instantiators[key] = instantiator
+            self._instantiators = instantiators
+
+    def _get_instantiators(self):
+        instantiators = self._instantiators or {}
+        if hasattr(self, "parent_parser"):
+            parent_instantiators = self.parent_parser._get_instantiators()
+            instantiators = instantiators.copy()
+            instantiators.update({k: v for k, v in parent_instantiators.items() if k not in instantiators})
+        return instantiators
+
     def instantiate_classes(
         self,
         cfg: Namespace,
@@ -1113,10 +1160,10 @@ class ArgumentParser(ParserDeprecations, ActionsContainer, ArgumentLinking, argp
                     pass
                 else:
                     if value is not None:
-                        with parser_context(parent_parser=self):
+                        with parser_context(parent_parser=self, class_instantiators=self._get_instantiators()):
                             parent[key] = component.instantiate_classes(value)
             else:
-                with parser_context(load_value_mode=self.parser_mode):
+                with parser_context(load_value_mode=self.parser_mode, class_instantiators=self._get_instantiators()):
                     component.instantiate_class(component, cfg)
 
         ActionLink.apply_instantiation_links(self, cfg, order=order)
diff --git a/jsonargparse/_signatures.py b/jsonargparse/_signatures.py
index 7ba724a..aeb25f3 100644
--- a/jsonargparse/_signatures.py
+++ b/jsonargparse/_signatures.py
@@ -8,7 +8,7 @@ from contextlib import suppress
 from typing import Any, Callable, List, Optional, Set, Tuple, Type, Union
 
 from ._actions import _ActionConfigLoad
-from ._common import is_dataclass_like, is_subclass
+from ._common import get_class_instantiator, is_dataclass_like, is_subclass
 from ._optionals import get_doc_short_description, pydantic_support
 from ._parameter_resolvers import (
     ParamData,
@@ -558,7 +558,8 @@ def group_instantiate_class(group, cfg):
         value = {}
         parent = cfg
         key = group.dest
-    parent[key] = group.group_class(**value)
+    instantiator_fn = get_class_instantiator()
+    parent[key] = instantiator_fn(group.group_class, **value)
 
 
 def strip_title(value):
diff --git a/jsonargparse/_typehints.py b/jsonargparse/_typehints.py
index eaa42b1..47f9bed 100644
--- a/jsonargparse/_typehints.py
+++ b/jsonargparse/_typehints.py
@@ -42,7 +42,7 @@ from ._actions import (
     _is_action_value_list,
     remove_actions,
 )
-from ._common import is_dataclass_like, is_subclass, parent_parser, parser_context
+from ._common import get_class_instantiator, is_dataclass_like, is_subclass, parent_parser, parser_context
 from ._loaders_dumpers import (
     get_loader_exceptions,
     load_value,
@@ -1044,13 +1044,16 @@ def adapt_class_type(value, serialize, instantiate_classes, sub_add_kwargs, prev
             if init_args:
                 value["init_args"] = init_args
             return value
+
+        instantiator_fn = get_class_instantiator()
+
         if skip_args:
 
             def partial_instance(*args):
-                return val_class(*args, **{**init_args, **dict_kwargs})
+                return instantiator_fn(val_class, *args, **{**init_args, **dict_kwargs})
 
             return partial_instance
-        return val_class(**{**init_args, **dict_kwargs})
+        return instantiator_fn(val_class, **{**init_args, **dict_kwargs})
 
     prev_init_args = prev_val.get("init_args") if isinstance(prev_val, Namespace) else None
 
diff --git a/jsonargparse/_util.py b/jsonargparse/_util.py
index 502e145..7c6c4a5 100644
--- a/jsonargparse/_util.py
+++ b/jsonargparse/_util.py
@@ -26,12 +26,11 @@ from typing import (
     Optional,
     Tuple,
     Type,
-    TypeVar,
     Union,
     get_type_hints,
 )
 
-from ._common import is_subclass, parser_capture, parser_context
+from ._common import ClassType, is_subclass, parser_capture, parser_context
 from ._deprecated import PathDeprecations
 from ._loaders_dumpers import json_dump, load_value
 from ._optionals import (
@@ -360,9 +359,6 @@ class ClassFromFunctionBase:
     wrapped_function: Callable
 
 
-ClassType = TypeVar("ClassType")
-
-
 def class_from_function(
     func: Callable[..., ClassType],
     func_return: Optional[Type[ClassType]] = None,


Test patch:
diff --git a/jsonargparse_tests/test_signatures.py b/jsonargparse_tests/test_signatures.py
index 1a84395..5f0e0b9 100644
--- a/jsonargparse_tests/test_signatures.py
+++ b/jsonargparse_tests/test_signatures.py
@@ -379,6 +379,20 @@ def test_add_class_docstring_parse_fail(parser, logger):
     assert "a1 description" not in help_str
 
 
+def test_add_class_custom_instantiator(parser):
+    def instantiate(cls, **kwargs):
+        instance = cls(**kwargs)
+        instance.call = "custom"
+        return instance
+
+    parser.add_class_arguments(Class0, "a")
+    parser.add_instantiator(instantiate, Class0)
+    cfg = parser.parse_args([])
+    init = parser.instantiate_classes(cfg)
+    assert isinstance(init.a, Class0)
+    assert init.a.call == "custom"
+
+
 # add_method_arguments tests
 
 
diff --git a/jsonargparse_tests/test_subclasses.py b/jsonargparse_tests/test_subclasses.py
index 06d6fb9..33e6906 100644
--- a/jsonargparse_tests/test_subclasses.py
+++ b/jsonargparse_tests/test_subclasses.py
@@ -326,6 +326,74 @@ def test_subclass_in_subcommand_with_global_default_config_file(parser, subparse
     assert cfg.fit.model.foo == 123
 
 
+# custom instantiation tests
+
+
+class CustomInstantiationBase:
+    pass
+
+
+class CustomInstantiationSub(CustomInstantiationBase):
+    pass
+
+
+def instantiator(value):
+    def instantiate(cls, **kwargs):
+        instance = cls(**kwargs)
+        instance.call = value
+        return instance
+
+    return instantiate
+
+
+def test_custom_instantiation_argument_type(parser):
+    parser.add_argument("--cls", type=CustomInstantiationBase)
+    parser.add_instantiator(instantiator("argument type"), CustomInstantiationBase)
+    cfg = parser.parse_args(["--cls=CustomInstantiationBase"])
+    init = parser.instantiate_classes(cfg)
+    assert isinstance(init.cls, CustomInstantiationBase)
+    assert init.cls.call == "argument type"
+
+
+def test_custom_instantiation_unused_for_subclass(parser):
+    parser.add_argument("--cls", type=CustomInstantiationBase)
+    parser.add_instantiator(instantiator("base"), CustomInstantiationBase, subclasses=False)
+    cfg = parser.parse_args(["--cls=CustomInstantiationSub"])
+    init = parser.instantiate_classes(cfg)
+    assert isinstance(init.cls, CustomInstantiationSub)
+    assert not hasattr(init.cls, "call")
+
+
+def test_custom_instantiation_used_for_subclass(parser):
+    parser.add_argument("--cls", type=CustomInstantiationBase)
+    parser.add_instantiator(instantiator("subclass"), CustomInstantiationBase, subclasses=True)
+    cfg = parser.parse_args(["--cls=CustomInstantiationSub"])
+    init = parser.instantiate_classes(cfg)
+    assert isinstance(init.cls, CustomInstantiationSub)
+    assert init.cls.call == "subclass"
+
+
+def test_custom_instantiation_prepend(parser):
+    parser.add_argument("--cls", type=CustomInstantiationBase)
+    parser.add_instantiator(instantiator("first"), CustomInstantiationSub)
+    parser.add_instantiator(instantiator("prepended"), CustomInstantiationBase, subclasses=True, prepend=True)
+    assert len(parser._instantiators) == 2
+    cfg = parser.parse_args(["--cls=CustomInstantiationSub"])
+    init = parser.instantiate_classes(cfg)
+    assert isinstance(init.cls, CustomInstantiationSub)
+    assert init.cls.call == "prepended"
+
+
+def test_custom_instantiation_replace(parser):
+    first_instantiator = instantiator("first")
+    second_instantiator = instantiator("second")
+    parser.add_argument("--cls", type=CustomInstantiationBase)
+    parser.add_instantiator(first_instantiator, CustomInstantiationBase)
+    parser.add_instantiator(second_instantiator, CustomInstantiationBase)
+    assert len(parser._instantiators) == 1
+    assert list(parser._instantiators.values())[0] is second_instantiator
+
+
 # environment tests
 
 
diff --git a/jsonargparse_tests/test_subcommands.py b/jsonargparse_tests/test_subcommands.py
index a5d2461..d69bd01 100644
--- a/jsonargparse_tests/test_subcommands.py
+++ b/jsonargparse_tests/test_subcommands.py
@@ -16,6 +16,7 @@ from jsonargparse import (
     strip_meta,
 )
 from jsonargparse_tests.conftest import get_parse_args_stdout, get_parser_help
+from jsonargparse_tests.test_subclasses import CustomInstantiationBase, instantiator
 
 
 @pytest.fixture
@@ -266,3 +267,23 @@ def test_subsubcommands_wrong_add_order(parser):
     with pytest.raises(ValueError) as ctx:
         subcommands1.add_subcommand("a", parser_s1_a)
     ctx.match("Multiple levels of subcommands must be added in level order")
+
+
+def test_subcommands_custom_instantiator(parser, subparser, subtests):
+    subparser.add_argument("--cls", type=CustomInstantiationBase)
+    subcommands = parser.add_subcommands()
+    subcommands.add_subcommand("cmd", subparser)
+
+    with subtests.test("main parser"):
+        parser.add_instantiator(instantiator("main parser"), CustomInstantiationBase)
+        cfg = parser.parse_args(["cmd", "--cls", "CustomInstantiationBase"])
+        init = parser.instantiate_classes(cfg)
+        assert isinstance(init.cmd.cls, CustomInstantiationBase)
+        assert init.cmd.cls.call == "main parser"
+
+    with subtests.test("subparser"):
+        subparser.add_instantiator(instantiator("subparser"), CustomInstantiationBase)
+        cfg = parser.parse_args(["cmd", "--cls", "CustomInstantiationBase"])
+        init = parser.instantiate_classes(cfg)
+        assert isinstance(init.cmd.cls, CustomInstantiationBase)
+        assert init.cmd.cls.call == "subparser"