Repository: omni-us/jsonargparse
Pull request #182 (2022-10-24 05:20:13)

Type Any support for instantiation of classes
## What does this PR do?

Fixes [pytorch-lightning#15115](https://github.com/Lightning-AI/lightning/issues/15115).

## Before submitting

- [x] Did you read the [contributing guideline](https://github.com/omni-us/jsonargparse/blob/master/CONTRIBUTING.rst)?
- [x] Did you update **the documentation**? (readme and public docstrings)
- [x] Did you write **unit tests** such that there is 100% coverage on related code? (required for bug fixes and new features)
- [x] Did you verify that new and existing **tests pass locally**?
- [x] Did you make sure that all changes preserve **backward compatibility**?
- [x] Did you update **the CHANGELOG**? (not for typos, docs, test updates, or minor internal changes/refactors)


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=182)

[![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=182&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=182&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=182&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=182&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=182&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=182&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=182&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=182&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=182&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=182&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=182&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=182&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=182) 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=182&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=182&metric=new_duplicated_lines_density&view=list)


gianmarcoaversanoenx: I tried to run the this script:

```python
from pytorch_lightning.cli import LightningCLI

from loguru import logger
import typing as ty

import torch
import torchvision.transforms as tfs
from torchvision.datasets import VisionDataset
from torch.utils.data import random_split, DataLoader, Subset
from pytorch_lightning import LightningDataModule
from torchvision.datasets import CIFAR10, CIFAR100


class BaseDataModule(LightningDataModule):
    """Base data module."""

    def __init__(
        self,
        dataclass: ty.Type[VisionDataset],
        batch_size: int,
        dims: ty.Sequence[int],
        num_classes: int,
        split: ty.Union[ty.Sequence[int], float],
        data_dir: str = ".data",
        num_workers: int = 1,
        transforms: tfs.Compose = tfs.Compose(
            [tfs.ToTensor(), tfs.Normalize((0.1307,), (0.3081,))]
        ),
        seed: int = None,
    ) -> None:
        """
        Args:
            dataclass (ty.Type[VisionDataset]):
                A dataset's class.
            batch_size (int):
                Batch size.
            dims (ty.Sequence[int]):
                Input dimensions.
            num_classes (int):
                Number of classes in the dataset.
            split (ty.Union[ty.Sequence[int], float]):
                If a sequence of integers, this will be the number of samples for the training and validation set. If a float, this will be the percentage of the training set.
            data_dir (str, optional): Defaults to ".data".
                Where to download the data.
            num_workers (int, optional): Defaults to 1.
            transforms (tfs.Compose, optional): Defaults to tfs.Compose( [ tfs.ToTensor(), tfs.Normalize((0.1307,), (0.3081,)), ] ).
            seed (int):
                Seed for random splitting.
        """
        super().__init__()  # type: ignore
        logger.debug(
            f"Initializing {self.__class__.__name__} with transforms {transforms}"
        )
        self.dataclass = dataclass
        self.data_dir = data_dir
        self.batch_size = batch_size
        self.num_workers = num_workers
        self.split = split
        self.transform = transforms
        self.seed = seed
        self.train: Subset
        self.val: Subset
        self.test: Subset
        # self.dims is returned when you call dm.size()
        # Setting default dims here because we know them.
        # Could optionally be assigned dynamically in dm.setup()
        self.dims = dims
        self.shape = dims
        self.num_classes = num_classes

    def size(self) -> ty.Sequence[int]:
        """Return size of samples."""
        return self.dims

    def prepare_data(self) -> None:
        """Download data."""
        self.dataclass(self.data_dir, train=True, download=True)
        self.dataclass(self.data_dir, train=False, download=True)

    def setup(self, stage: str = None) -> None:  # type: ignore
        """Create train, val and test."""
        # Assign train/val datasets for use in dataloaders
        if stage == "fit" or stage is None:
            full = self.dataclass(self.data_dir, train=True, transform=self.transform)
            if isinstance(self.split, float):
                n_full = len(full)
                n_train = max(int(self.split * n_full), 1)
                self.split = (n_train, n_full - n_train)
            if isinstance(self.seed, int):
                gen = torch.Generator().manual_seed(self.seed)
            else:
                gen = None
            self.train, self.val = random_split(full, self.split, generator=gen)
        # Assign test dataset for use in dataloader(s)
        if stage == "test" or stage is None:
            self.test = self.dataclass(
                self.data_dir, train=False, transform=self.transform
            )

    def train_dataloader(self) -> DataLoader:
        """Training dataloader."""
        return DataLoader(
            self.train,
            batch_size=self.batch_size,
            num_workers=self.num_workers,
        )

    def val_dataloader(self) -> DataLoader:
        """Validation dataloader."""
        return DataLoader(
            self.val,
            batch_size=self.batch_size,
            num_workers=self.num_workers,
        )

    def test_dataloader(self) -> DataLoader:
        """Test dataloader."""
        return DataLoader(
            self.test,
            batch_size=self.batch_size,
            num_workers=self.num_workers,
        )


class CIFARDataModule(BaseDataModule):
    """CIFAR data module."""

    def __init__(
        self,
        data_dir: str = ".data",
        batch_size: int = 32,
        num_workers: int = 1,
        cifar_n: int = 10,
        split: ty.Union[ty.Sequence[int], float] = [45000, 5000],  # 50000 in total
        transforms: tfs.Compose = tfs.Compose(
            transforms=[
                tfs.Resize(64),
                tfs.ToTensor(),
                tfs.Normalize((0.5, 0.5, 0.5), (0.5, 0.5, 0.5)),
            ]
        ),
        seed: int = None,
    ) -> None:
        if cifar_n == 10:
            dataclass = CIFAR10
        elif cifar_n == 100:
            dataclass = CIFAR100
        else:
            raise NotImplementedError(f"Unsupported CIFAR{cifar_n} dataset.")
        super().__init__(
            dataclass=dataclass,
            dims=(3, 64, 64),
            num_classes=10,
            batch_size=batch_size,
            split=split,
            data_dir=data_dir,
            num_workers=num_workers,
            transforms=transforms,
            seed=seed,
        )


if __name__ == "__main__":
    # to run, run something like: python main.py fit -c default.yaml
    cli = LightningCLI()
```

with this config:

```yaml
seed_everything: true
trainer:
  logger: true
  enable_checkpointing: true
  callbacks:
    - class_path: pytorch_lightning.callbacks.EarlyStopping
      init_args:
        monitor: 'loss/val'
        min_delta: 0. # minimum change in the monitored quantity
        patience: 3 # checks with no improvement after which training will be stopped
        verbose: False # verbosity mode
        mode: "min"
        strict: True # crash the training if monitor is not found
        check_finite: True # stops training when the monitor becomes NaN or infinite
  default_root_dir: null
  gradient_clip_val: null
  gradient_clip_algorithm: null
  num_nodes: 1
  num_processes: null
  devices: null
  gpus: null
  auto_select_gpus: false
  tpu_cores: null
  ipus: null
  enable_progress_bar: true
  overfit_batches: 0.0
  track_grad_norm: -1
  check_val_every_n_epoch: 1
  fast_dev_run: false
  accumulate_grad_batches: null
  max_epochs: null
  min_epochs: null
  max_steps: -1
  min_steps: null
  max_time: null
  limit_train_batches: null
  limit_val_batches: null
  limit_test_batches: null
  limit_predict_batches: null
  val_check_interval: null
  log_every_n_steps: 50
  accelerator: null
  strategy: null
  sync_batchnorm: false
  precision: 32
  enable_model_summary: true
  weights_save_path: null
  num_sanity_val_steps: 2
  resume_from_checkpoint: null
  profiler: null
  benchmark: null
  deterministic: null
  reload_dataloaders_every_n_epochs: 0
  auto_lr_find: false
  replace_sampler_ddp: true
  detect_anomaly: false
  auto_scale_batch_size: false
  plugins: null
  amp_backend: native
  amp_level: null
  move_metrics_to_cpu: false
  multiple_trainloader_mode: max_size_cycle
ckpt_path: null
model:
  class_path: pl_bolts.models.gans.GAN
  init_args:
    input_channels: 3
    input_width: 64
    input_height: 64
data:
  class_path: CIFARDataModule
  init_args:
    data_dir: .data
    batch_size: 32
    num_workers: 1
    cifar_n: 10
    split:
    - 45000
    - 5000
    transforms:
      class_path: torchvision.transforms.transforms.Compose
      init_args:
        transforms:
          - class_path: torchvision.transforms.transforms.Resize
            init_args:
              size: 64
          - class_path: torchvision.transforms.transforms.ToTensor
          - class_path: torchvision.transforms.transforms.Normalize
            init_args:
              mean: [0.5, 0.5, 0.5]
              std: [0.5, 0.5, 0.5]
```

And no error was raised and the training was started. I guess this means it works? Should I commit these files? Add them to the tests?
gianmarcoaversanoenx: Ok, great then! Yep, we could merge. Is there any other action I should take?
mauvilsa: > Is there any other action I should take?

No, nothing else. I guess in about a week this will be part of jsonargparse v4.16.0 and available on pypi.
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=182)

[![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=182&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=182&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=182&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=182&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=182&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=182&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=182&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=182&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=omni-us_jsonargparse&pullRequest=182&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=182&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=182&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=omni-us_jsonargparse&pullRequest=182&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=182) 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=182&metric=new_duplicated_lines_density&view=list) [0.0% Duplication](https://sonarcloud.io/component_measures?id=omni-us_jsonargparse&pullRequest=182&metric=new_duplicated_lines_density&view=list)



Patch:
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index 1a231aa..4f9ce8f 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -12,9 +12,16 @@ The semantic versioning only considers the public API as described in
 paths are considered internals and can change in minor and patch releases.
 
 
-v4.15.3 (2022-10-??)
+
+v4.16.0 (2022-10-??)
 --------------------
 
+Added
+^^^^^
+- Type ``Any`` now parses and instantiates classes when given dict that follows
+  subclass specification `pytorch-lightning#15115
+  <https://github.com/Lightning-AI/lightning/issues/15115>`__.
+
 Fixed
 ^^^^^
 - Fail to import on Python 3.7 when typing_extensions not installed `#178
diff --git a/README.rst b/README.rst
index 59630c7..25983ea 100644
--- a/README.rst
+++ b/README.rst
@@ -1494,6 +1494,13 @@ new group automatically; 2) the argument values can be given in an independent
 config file by specifying a path to it; and 3) by default sets a useful
 ``metavar`` and ``help`` strings.
 
+.. note::
+
+    Classes will be parsed and instantiated when given as value a dict with
+    ``class_path`` and ``init_args`` if the corresponding parameter has type
+    ``Any``, or when ``fail_untyped=False`` which defaults to type ``Any``.
+
+
 Command line
 ------------
 
diff --git a/jsonargparse/typehints.py b/jsonargparse/typehints.py
index e69f97d..5c5ca9c 100644
--- a/jsonargparse/typehints.py
+++ b/jsonargparse/typehints.py
@@ -503,6 +503,7 @@ def adapt_typehints(val, typehint, serialize=False, instantiate_classes=False, p
                 val, _ = parse_value_or_config(val, enable_path=False, simple_types=True)
             except get_loader_exceptions():
                 pass
+        val = adapt_classes_any(val, serialize, instantiate_classes, sub_add_kwargs)
 
     # Literal
     elif typehint_origin == Literal:
@@ -895,6 +896,28 @@ def adapt_class_type(value, serialize, instantiate_classes, sub_add_kwargs, prev
     return value
 
 
+def adapt_classes_any(val, serialize, instantiate_classes, sub_add_kwargs):
+    if is_subclass_spec(val):
+        orig_val = val
+        val = subclass_spec_as_namespace(val)
+        init_args = val.get('init_args')
+        if init_args and not instantiate_classes:
+            for subkey, subval in init_args.__dict__.items():
+                init_args[subkey] = adapt_classes_any(subval, serialize, instantiate_classes, sub_add_kwargs)
+            val['init_args'] = init_args
+        try:
+            val = adapt_class_type(val, serialize, instantiate_classes, sub_add_kwargs)
+        except Exception:
+            return orig_val
+    elif isinstance(val, list):
+        for num, subval in enumerate(val):
+            val[num] = adapt_classes_any(subval, serialize, instantiate_classes, sub_add_kwargs)
+    elif isinstance(val, dict):
+        for key, subval in val.items():
+            val[key] = adapt_classes_any(subval, serialize, instantiate_classes, sub_add_kwargs)
+    return val
+
+
 def not_append_diff(val1, val2):
     if isinstance(val1, list) and isinstance(val2, list):
         val1 = [x.get('class_path') if is_subclass_spec(x) else x for x in val1]


Test patch:
diff --git a/jsonargparse_tests/test_typehints.py b/jsonargparse_tests/test_typehints.py
index 09d2a88..f44b831 100755
--- a/jsonargparse_tests/test_typehints.py
+++ b/jsonargparse_tests/test_typehints.py
@@ -10,7 +10,7 @@ import unittest
 import uuid
 import warnings
 import yaml
-from calendar import Calendar
+from calendar import Calendar, HTMLCalendar, TextCalendar
 from contextlib import redirect_stderr, redirect_stdout
 from copy import deepcopy
 from datetime import datetime
@@ -396,6 +396,104 @@ class TypeHintsTests(unittest.TestCase):
         self.assertEqual('[[[', parser.parse_args(['--any=[[['])['any'])
 
 
+    def test_type_any_subclasses(self):
+        class Class:
+            def __init__(self, cal1: Calendar, cal2: Any):
+                self.cal1 = cal1
+                self.cal2 = cal2
+
+        parser = ArgumentParser(error_handler=None)
+        parser.add_argument('--any', type=Any)
+
+        with mock_module(Class) as module:
+            value = {
+                'class_path': f'{module}.Class',
+                'init_args': {
+                    'cal1': {
+                        'class_path': 'calendar.TextCalendar',
+                        'init_args': {'firstweekday': 1},
+                    },
+                    'cal2': {
+                        'class_path': 'calendar.HTMLCalendar',
+                        'init_args': {'firstweekday': 2},
+                    },
+                },
+            }
+
+            cfg = parser.parse_args([f'--any={value}'])
+            init = parser.instantiate_classes(cfg)
+            self.assertIsInstance(init.any, Class)
+            self.assertIsInstance(init.any.cal1, TextCalendar)
+            self.assertIsInstance(init.any.cal2, HTMLCalendar)
+            self.assertEquals(init.any.cal1.firstweekday, 1)
+            self.assertEquals(init.any.cal2.firstweekday, 2)
+
+            value['init_args']['cal2']['class_path'] = 'does.not.exist'
+            cfg = parser.parse_args([f'--any={value}'])
+            self.assertIsInstance(cfg.any.init_args.cal1, Namespace)
+            self.assertIsInstance(cfg.any.init_args.cal2, dict)
+            init = parser.instantiate_classes(cfg)
+            self.assertIsInstance(init.any, Class)
+            self.assertIsInstance(init.any.cal1, TextCalendar)
+            self.assertIsInstance(init.any.cal2, dict)
+            self.assertEquals(init.any.cal1.firstweekday, 1)
+            self.assertEquals(init.any.cal2['init_args']['firstweekday'], 2)
+
+            value['init_args']['cal1']['class_path'] = 'does.not.exist'
+            cfg = parser.parse_args([f'--any={value}'])
+            self.assertIsInstance(cfg.any, dict)
+
+
+    def test_type_any_list_of_subclasses(self):
+        parser = ArgumentParser(error_handler=None)
+        parser.add_argument('--any', type=Any)
+
+        value = [
+            {
+                'class_path': 'calendar.TextCalendar',
+                'init_args': {'firstweekday': 1},
+            },
+            {
+                'class_path': 'calendar.HTMLCalendar',
+                'init_args': {'firstweekday': 2},
+            },
+        ]
+
+        cfg = parser.parse_args([f'--any={value}'])
+        init = parser.instantiate_classes(cfg)
+        self.assertIsInstance(init.any, list)
+        self.assertEqual(len(init.any), 2)
+        self.assertIsInstance(init.any[0], TextCalendar)
+        self.assertIsInstance(init.any[1], HTMLCalendar)
+        self.assertEquals(init.any[0].firstweekday, 1)
+        self.assertEquals(init.any[1].firstweekday, 2)
+
+
+    def test_type_any_dict_of_subclasses(self):
+        parser = ArgumentParser(error_handler=None)
+        parser.add_argument('--any', type=Any)
+
+        value = {
+            'k1': {
+                'class_path': 'calendar.TextCalendar',
+                'init_args': {'firstweekday': 1},
+            },
+            'k2': {
+                'class_path': 'calendar.HTMLCalendar',
+                'init_args': {'firstweekday': 2},
+            },
+        }
+
+        cfg = parser.parse_args([f'--any={value}'])
+        init = parser.instantiate_classes(cfg)
+        self.assertIsInstance(init.any, dict)
+        self.assertEqual(len(init.any), 2)
+        self.assertIsInstance(init.any['k1'], TextCalendar)
+        self.assertIsInstance(init.any['k2'], HTMLCalendar)
+        self.assertEquals(init.any['k1'].firstweekday, 1)
+        self.assertEquals(init.any['k2'].firstweekday, 2)
+
+
     def test_union_subtypes_order(self):
         for subtypes, arg, expected in [
             ((bool, str), '=true', True),