Repository: stan-dev/cmdstanpy
Pull request #412 (2021-07-26 15:35:41)

Typing fixes
#### Submission Checklist

- [x] Run unit tests
- [x] Declare copyright holder and open-source license: see below

#### Summary

This PR mainly makes changes to the optional typing to bring them up to the standards used by mypy and other linters, as discussed here https://github.com/stan-dev/cmdstanpy/pull/408#discussion_r670471957. Any example of this is turning something like 

`dims: list = None`

into

`dims: Optional[List[int]] = None`

The package now very nearly passes `mypy --strict`, with all of the remaining errors being benign. Fixing them requires using `numpy.typing`, which isn't available to us until we make the numpy minimum version 1.21.

Some minor semantic changes were made to make the types clearer. These should have no outward-facing impact, and all existing tests still pass. This mainly included changing the default values of some variables to match their eventual types, and changing calls like `isinstance(x, number.Integral)` to the more typical `isinstance(x, int)`.

We may want to discuss including mypy in the CI checks or in the pre-commit configuration if we would like to enforce this behavior going forward

#### Copyright and Licensing

By submitting this pull request, the copyright holder is agreeing to license the submitted work under the following licenses:
- Code: BSD 3-clause (https://opensource.org/licenses/BSD-3-Clause)




Patch:
diff --git a/cmdstanpy/__init__.py b/cmdstanpy/__init__.py
index 3801a10..537b9b0 100644
--- a/cmdstanpy/__init__.py
+++ b/cmdstanpy/__init__.py
@@ -25,7 +25,7 @@ _DOT_CMDSTANPY = '.cmdstanpy'
 _DOT_CMDSTAN = '.cmdstan'
 
 
-def _cleanup_tmpdir():
+def _cleanup_tmpdir() -> None:
     """Force deletion of _TMPDIR."""
     print('deleting tmpfiles dir: {}'.format(_TMPDIR))
     shutil.rmtree(_TMPDIR, ignore_errors=True)
@@ -37,13 +37,13 @@ atexit.register(_cleanup_tmpdir)
 
 from ._version import __version__  # noqa
 from .model import CmdStanModel  # noqa
-from .stanfit import (
-    from_csv,
+from .stanfit import (  # noqa
     CmdStanGQ,
     CmdStanMCMC,
     CmdStanMLE,
     CmdStanVB,
-)  # noqa
+    from_csv,
+)
 from .utils import set_cmdstan_path  # noqa
 from .utils import cmdstan_path, install_cmdstan, set_make_env
 
diff --git a/cmdstanpy/cmdstan_args.py b/cmdstanpy/cmdstan_args.py
index 7924125..8ce00ef 100644
--- a/cmdstanpy/cmdstan_args.py
+++ b/cmdstanpy/cmdstan_args.py
@@ -4,9 +4,8 @@ CmdStan arguments
 import logging
 import os
 from enum import Enum, auto
-from numbers import Integral, Real
 from time import time
-from typing import List, Union
+from typing import Any, Dict, List, Optional, Union
 
 from numpy.random import RandomState
 
@@ -26,7 +25,7 @@ class Method(Enum):
     GENERATE_QUANTITIES = auto()
     VARIATIONAL = auto()
 
-    def __repr__(self):
+    def __repr__(self) -> str:
         return '<%s.%s>' % (self.__class__.__name__, self.name)
 
 
@@ -35,18 +34,18 @@ class SamplerArgs:
 
     def __init__(
         self,
-        iter_warmup: int = None,
-        iter_sampling: int = None,
+        iter_warmup: Optional[int] = None,
+        iter_sampling: Optional[int] = None,
         save_warmup: bool = False,
-        thin: int = None,
-        max_treedepth: int = None,
-        metric: Union[str, List[str]] = None,
-        step_size: Union[float, List[float]] = None,
+        thin: Optional[int] = None,
+        max_treedepth: Optional[int] = None,
+        metric: Union[str, List[str], None] = None,
+        step_size: Union[float, List[float], None] = None,
         adapt_engaged: bool = True,
-        adapt_delta: float = None,
-        adapt_init_phase: int = None,
-        adapt_metric_window: int = None,
-        adapt_step_size: int = None,
+        adapt_delta: Optional[float] = None,
+        adapt_init_phase: Optional[int] = None,
+        adapt_metric_window: Optional[int] = None,
+        adapt_step_size: Optional[int] = None,
         fixed_param: bool = False,
     ) -> None:
         """Initialize object."""
@@ -66,7 +65,7 @@ class SamplerArgs:
         self.fixed_param = fixed_param
         self.diagnostic_file = None
 
-    def validate(self, chains: int) -> None:
+    def validate(self, chains: Optional[int]) -> None:
         """
         Check arguments correctness and consistency.
 
@@ -74,7 +73,7 @@ class SamplerArgs:
         * if file(s) for metric are supplied, check contents.
         * length of per-chain lists equals specified # of chains
         """
-        if not isinstance(chains, Integral) or chains < 1:
+        if not isinstance(chains, int) or chains < 1:
             raise ValueError(
                 'sampler expects number of chains to be greater than 0'
             )
@@ -103,9 +102,7 @@ class SamplerArgs:
                 raise ValueError(msg)
 
         if self.iter_warmup is not None:
-            if self.iter_warmup < 0 or not isinstance(
-                self.iter_warmup, Integral
-            ):
+            if self.iter_warmup < 0 or not isinstance(self.iter_warmup, int):
                 raise ValueError(
                     'iter_warmup must be a non-negative integer,'
                     ' found {}'.format(self.iter_warmup)
@@ -116,28 +113,28 @@ class SamplerArgs:
                 )
         if self.iter_sampling is not None:
             if self.iter_sampling < 0 or not isinstance(
-                self.iter_sampling, Integral
+                self.iter_sampling, int
             ):
                 raise ValueError(
                     'iter_sampling must be a non-negative integer,'
                     ' found {}'.format(self.iter_sampling)
                 )
         if self.thin is not None:
-            if self.thin < 1 or not isinstance(self.thin, Integral):
+            if self.thin < 1 or not isinstance(self.thin, int):
                 raise ValueError(
                     'thin must be a positive integer,'
                     'found {}'.format(self.thin)
                 )
         if self.max_treedepth is not None:
             if self.max_treedepth < 1 or not isinstance(
-                self.max_treedepth, Integral
+                self.max_treedepth, int
             ):
                 raise ValueError(
                     'max_treedepth must be a positive integer,'
                     ' found {}'.format(self.max_treedepth)
                 )
         if self.step_size is not None:
-            if isinstance(self.step_size, Real):
+            if isinstance(self.step_size, (float, int)):
                 if self.step_size <= 0:
                     raise ValueError(
                         'step_size must be > 0, found {}'.format(self.step_size)
@@ -218,7 +215,7 @@ class SamplerArgs:
                 )
         if self.adapt_init_phase is not None:
             if self.adapt_init_phase < 0 or not isinstance(
-                self.adapt_init_phase, Integral
+                self.adapt_init_phase, int
             ):
                 raise ValueError(
                     'adapt_init_phase must be a non-negative integer,'
@@ -226,7 +223,7 @@ class SamplerArgs:
                 )
         if self.adapt_metric_window is not None:
             if self.adapt_metric_window < 0 or not isinstance(
-                self.adapt_metric_window, Integral
+                self.adapt_metric_window, int
             ):
                 raise ValueError(
                     'adapt_metric_window must be a non-negative integer,'
@@ -234,7 +231,7 @@ class SamplerArgs:
                 )
         if self.adapt_step_size is not None:
             if self.adapt_step_size < 0 or not isinstance(
-                self.adapt_step_size, Integral
+                self.adapt_step_size, int
             ):
                 raise ValueError(
                     'adapt_step_size must be a non-negative integer,'
@@ -259,7 +256,7 @@ class SamplerArgs:
                 ' or adaptation parameters.'
             )
 
-    def compose(self, idx: int, cmd: List) -> str:
+    def compose(self, idx: int, cmd: List[str]) -> List[str]:
         """
         Compose CmdStan command for method-specific non-default arguments.
         """
@@ -316,15 +313,15 @@ class OptimizeArgs:
 
     def __init__(
         self,
-        algorithm: str = None,
-        init_alpha: Real = None,
-        iter: int = None,
-        tol_obj: Real = None,
-        tol_rel_obj: Real = None,
-        tol_grad: Real = None,
-        tol_rel_grad: Real = None,
-        tol_param: Real = None,
-        history_size: int = None,
+        algorithm: Optional[str] = None,
+        init_alpha: Optional[float] = None,
+        iter: Optional[int] = None,
+        tol_obj: Optional[float] = None,
+        tol_rel_obj: Optional[float] = None,
+        tol_grad: Optional[float] = None,
+        tol_rel_grad: Optional[float] = None,
+        tol_param: Optional[float] = None,
+        history_size: Optional[int] = None,
     ) -> None:
 
         self.algorithm = algorithm
@@ -336,8 +333,11 @@ class OptimizeArgs:
         self.tol_rel_grad = tol_rel_grad
         self.tol_param = tol_param
         self.history_size = history_size
+        self.thin = None
 
-    def validate(self, chains=None) -> None:  # pylint: disable=unused-argument
+    def validate(
+        self, chains: Optional[int] = None  # pylint: disable=unused-argument
+    ) -> None:
         """
         Check arguments correctness and consistency.
         """
@@ -356,14 +356,14 @@ class OptimizeArgs:
                 raise ValueError(
                     'init_alpha must not be set when algorithm is Newton'
                 )
-            if isinstance(self.init_alpha, Real):
+            if isinstance(self.init_alpha, float):
                 if self.init_alpha <= 0:
                     raise ValueError('init_alpha must be greater than 0')
             else:
                 raise ValueError('init_alpha must be type of float')
 
         if self.iter is not None:
-            if isinstance(self.iter, Integral):
+            if isinstance(self.iter, int):
                 if self.iter < 0:
                     raise ValueError('iter must be greater than 0')
             else:
@@ -374,7 +374,7 @@ class OptimizeArgs:
                 raise ValueError(
                     'tol_obj must not be set when algorithm is Newton'
                 )
-            if isinstance(self.tol_obj, Real):
+            if isinstance(self.tol_obj, float):
                 if self.tol_obj <= 0:
                     raise ValueError('tol_obj must be greater than 0')
             else:
@@ -385,7 +385,7 @@ class OptimizeArgs:
                 raise ValueError(
                     'tol_rel_obj must not be set when algorithm is Newton'
                 )
-            if isinstance(self.tol_rel_obj, Real):
+            if isinstance(self.tol_rel_obj, float):
                 if self.tol_rel_obj <= 0:
                     raise ValueError('tol_rel_obj must be greater than 0')
             else:
@@ -396,7 +396,7 @@ class OptimizeArgs:
                 raise ValueError(
                     'tol_grad must not be set when algorithm is Newton'
                 )
-            if isinstance(self.tol_grad, Real):
+            if isinstance(self.tol_grad, float):
                 if self.tol_grad <= 0:
                     raise ValueError('tol_grad must be greater than 0')
             else:
@@ -407,7 +407,7 @@ class OptimizeArgs:
                 raise ValueError(
                     'tol_rel_grad must not be set when algorithm is Newton'
                 )
-            if isinstance(self.tol_rel_grad, Real):
+            if isinstance(self.tol_rel_grad, float):
                 if self.tol_rel_grad <= 0:
                     raise ValueError('tol_rel_grad must be greater than 0')
             else:
@@ -418,7 +418,7 @@ class OptimizeArgs:
                 raise ValueError(
                     'tol_param must not be set when algorithm is Newton'
                 )
-            if isinstance(self.tol_param, Real):
+            if isinstance(self.tol_param, float):
                 if self.tol_param <= 0:
                     raise ValueError('tol_param must be greater than 0')
             else:
@@ -430,14 +430,14 @@ class OptimizeArgs:
                     'history_size must not be set when algorithm is '
                     'Newton or BFGS'
                 )
-            if isinstance(self.history_size, Integral):
+            if isinstance(self.history_size, int):
                 if self.history_size < 0:
                     raise ValueError('history_size must be greater than 0')
             else:
                 raise ValueError('history_size must be type of int')
 
     # pylint: disable=unused-argument
-    def compose(self, idx: int, cmd: List) -> str:
+    def compose(self, idx: int, cmd: List[str]) -> List[str]:
         """compose command string for CmdStan for non-default arg values."""
         cmd.append('method=optimize')
         if self.algorithm:
@@ -469,7 +469,9 @@ class GenerateQuantitiesArgs:
         """Initialize object."""
         self.sample_csv_files = csv_files
 
-    def validate(self, chains: int) -> None:  # pylint: disable=unused-argument
+    def validate(
+        self, chains: Optional[int] = None  # pylint: disable=unused-argument
+    ) -> None:
         """
         Check arguments correctness and consistency.
 
@@ -481,7 +483,7 @@ class GenerateQuantitiesArgs:
                     'Invalid path for sample csv file: {}'.format(csv)
                 )
 
-    def compose(self, idx: int, cmd: List) -> str:
+    def compose(self, idx: int, cmd: List[str]) -> List[str]:
         """
         Compose CmdStan command for method-specific non-default arguments.
         """
@@ -497,16 +499,16 @@ class VariationalArgs:
 
     def __init__(
         self,
-        algorithm: str = None,
-        iter: int = None,
-        grad_samples: int = None,
-        elbo_samples: int = None,
-        eta: Real = None,
-        adapt_iter: int = None,
+        algorithm: Optional[str] = None,
+        iter: Optional[int] = None,
+        grad_samples: Optional[int] = None,
+        elbo_samples: Optional[int] = None,
+        eta: Optional[float] = None,
+        adapt_iter: Optional[int] = None,
         adapt_engaged: bool = True,
-        tol_rel_obj: Real = None,
-        eval_elbo: int = None,
-        output_samples: int = None,
+        tol_rel_obj: Optional[float] = None,
+        eval_elbo: Optional[int] = None,
+        output_samples: Optional[int] = None,
     ) -> None:
         self.algorithm = algorithm
         self.iter = iter
@@ -519,7 +521,9 @@ class VariationalArgs:
         self.eval_elbo = eval_elbo
         self.output_samples = output_samples
 
-    def validate(self, chains=None) -> None:  # pylint: disable=unused-argument
+    def validate(
+        self, chains: Optional[int] = None  # pylint: disable=unused-argument
+    ) -> None:
         """
         Check arguments correctness and consistency.
         """
@@ -533,56 +537,52 @@ class VariationalArgs:
                 )
             )
         if self.iter is not None:
-            if self.iter < 1 or not isinstance(self.iter, Integral):
+            if self.iter < 1 or not isinstance(self.iter, int):
                 raise ValueError(
                     'iter must be a positive integer,'
                     ' found {}'.format(self.iter)
                 )
         if self.grad_samples is not None:
-            if self.grad_samples < 1 or not isinstance(
-                self.grad_samples, Integral
-            ):
+            if self.grad_samples < 1 or not isinstance(self.grad_samples, int):
                 raise ValueError(
                     'grad_samples must be a positive integer,'
                     ' found {}'.format(self.grad_samples)
                 )
         if self.elbo_samples is not None:
-            if self.elbo_samples < 1 or not isinstance(
-                self.elbo_samples, Integral
-            ):
+            if self.elbo_samples < 1 or not isinstance(self.elbo_samples, int):
                 raise ValueError(
                     'elbo_samples must be a positive integer,'
                     ' found {}'.format(self.elbo_samples)
                 )
         if self.eta is not None:
-            if self.eta < 0 or not isinstance(self.eta, (Integral, Real)):
+            if self.eta < 0 or not isinstance(self.eta, (int, float)):
                 raise ValueError(
                     'eta must be a non-negative number,'
                     ' found {}'.format(self.eta)
                 )
         if self.adapt_iter is not None:
-            if self.adapt_iter < 1 or not isinstance(self.adapt_iter, Integral):
+            if self.adapt_iter < 1 or not isinstance(self.adapt_iter, int):
                 raise ValueError(
                     'adapt_iter must be a positive integer,'
                     ' found {}'.format(self.adapt_iter)
                 )
         if self.tol_rel_obj is not None:
             if self.tol_rel_obj <= 0 or not isinstance(
-                self.tol_rel_obj, (Integral, Real)
+                self.tol_rel_obj, (int, float)
             ):
                 raise ValueError(
                     'tol_rel_obj must be a positive number,'
                     ' found {}'.format(self.tol_rel_obj)
                 )
         if self.eval_elbo is not None:
-            if self.eval_elbo < 1 or not isinstance(self.eval_elbo, Integral):
+            if self.eval_elbo < 1 or not isinstance(self.eval_elbo, int):
                 raise ValueError(
                     'eval_elbo must be a positive integer,'
                     ' found {}'.format(self.eval_elbo)
                 )
         if self.output_samples is not None:
             if self.output_samples < 1 or not isinstance(
-                self.output_samples, Integral
+                self.output_samples, int
             ):
                 raise ValueError(
                     'output_samples must be a positive integer,'
@@ -590,7 +590,7 @@ class VariationalArgs:
                 )
 
     # pylint: disable=unused-argument
-    def compose(self, idx: int, cmd: List) -> str:
+    def compose(self, idx: int, cmd: List[str]) -> List[str]:
         """
         Compose CmdStan command for method-specific non-default arguments.
         """
@@ -631,20 +631,20 @@ class CmdStanArgs:
     def __init__(
         self,
         model_name: str,
-        model_exe: str,
+        model_exe: Optional[str],
         chain_ids: Union[List[int], None],
         method_args: Union[
             SamplerArgs, OptimizeArgs, GenerateQuantitiesArgs, VariationalArgs
         ],
-        data: Union[str, dict] = None,
-        seed: Union[int, List[int]] = None,
-        inits: Union[int, float, str, List[str]] = None,
-        output_dir: str = None,
-        sig_figs: str = None,
+        data: Union[str, Dict[str, Any], None] = None,
+        seed: Union[int, List[int], None] = None,
+        inits: Union[int, float, str, List[str], None] = None,
+        output_dir: Optional[str] = None,
+        sig_figs: Optional[int] = None,
         save_diagnostics: bool = False,
         save_profile: bool = False,
-        refresh: int = None,
-        logger: logging.Logger = None,
+        refresh: Optional[int] = None,
+        logger: Optional[logging.Logger] = None,
     ) -> None:
         """Initialize object."""
         self.model_name = model_name
@@ -790,7 +790,7 @@ class CmdStanArgs:
             raise ValueError('data must be string or dict')
 
         if self.inits is not None:
-            if isinstance(self.inits, (Integral, Real)):
+            if isinstance(self.inits, (float, int)):
                 if self.inits < 0:
                     raise ValueError(
                         'inits must be > 0, found {}'.format(self.inits)
@@ -826,13 +826,13 @@ class CmdStanArgs:
         idx: int,
         csv_file: str,
         *,
-        diagnostic_file: str = None,
-        profile_file: str = None
-    ) -> str:
+        diagnostic_file: Optional[str] = None,
+        profile_file: Optional[str] = None
+    ) -> List[str]:
         """
         Compose CmdStan command for non-default arguments.
         """
-        cmd = []
+        cmd: List[str] = []
         if idx is not None and self.chain_ids is not None:
             if idx < 0 or idx > len(self.chain_ids) - 1:
                 raise ValueError(
@@ -840,10 +840,10 @@ class CmdStanArgs:
                         idx, len(self.chain_ids)
                     )
                 )
-            cmd.append(self.model_exe)
+            cmd.append(self.model_exe)  # type: ignore # guaranteed by validate
             cmd.append('id={}'.format(self.chain_ids[idx]))
         else:
-            cmd.append(self.model_exe)
+            cmd.append(self.model_exe)  # type: ignore # guaranteed by validate
 
         if self.seed is not None:
             if not isinstance(self.seed, list):
diff --git a/cmdstanpy/compiler_opts.py b/cmdstanpy/compiler_opts.py
index ae2d2ba..5d6cf9b 100644
--- a/cmdstanpy/compiler_opts.py
+++ b/cmdstanpy/compiler_opts.py
@@ -5,7 +5,7 @@ Makefile options for stanc and C++ compilers
 import logging
 import os
 from pathlib import Path
-from typing import Dict, List
+from typing import Any, Dict, List, Optional, Union
 
 from cmdstanpy.utils import get_logger
 
@@ -16,7 +16,7 @@ STANC_OPTS = [
     'warn-uninitialized',
     'include_paths',
     'name',
-    "warn-pedantic",
+    'warn-pedantic',
 ]
 
 STANC_IGNORE_OPTS = [
@@ -60,13 +60,14 @@ class CompilerOptions:
 
     def __init__(
         self,
-        stanc_options: Dict = None,
-        cpp_options: Dict = None,
-        logger: logging.Logger = None,
+        *,
+        stanc_options: Optional[Dict[str, Any]] = None,
+        cpp_options: Optional[Dict[str, Any]] = None,
+        logger: Optional[logging.Logger] = None,
     ) -> None:
         """Initialize object."""
-        self._stanc_options = stanc_options
-        self._cpp_options = cpp_options
+        self._stanc_options = stanc_options if stanc_options is not None else {}
+        self._cpp_options = cpp_options if cpp_options is not None else {}
         self._logger = logger or get_logger()
 
     def __repr__(self) -> str:
@@ -75,12 +76,12 @@ class CompilerOptions:
         )
 
     @property
-    def stanc_options(self) -> Dict:
+    def stanc_options(self) -> Dict[str, Union[bool, int, str]]:
         """Stanc compiler options."""
         return self._stanc_options
 
     @property
-    def cpp_options(self) -> Dict:
+    def cpp_options(self) -> Dict[str, Union[bool, int]]:
         """C++ compiler options."""
         return self._cpp_options
 
diff --git a/cmdstanpy/install_cmdstan.py b/cmdstanpy/install_cmdstan.py
index f08dcb9..d3d69c5 100644
--- a/cmdstanpy/install_cmdstan.py
+++ b/cmdstanpy/install_cmdstan.py
@@ -29,6 +29,7 @@ import urllib.request
 from collections import OrderedDict
 from pathlib import Path
 from time import sleep
+from typing import Callable, Dict, Iterator, Optional
 
 from cmdstanpy import _DOT_CMDSTAN, _DOT_CMDSTANPY
 from cmdstanpy.utils import validate_dir
@@ -45,7 +46,7 @@ class CmdStanInstallError(RuntimeError):
 
 
 @contextlib.contextmanager
-def pushd(new_dir: str):
+def pushd(new_dir: str) -> Iterator[None]:
     """Acts like pushd/popd."""
     previous_dir = os.getcwd()
     os.chdir(new_dir)
@@ -53,7 +54,7 @@ def pushd(new_dir: str):
     os.chdir(previous_dir)
 
 
-def usage():
+def usage() -> None:
     """Print usage."""
     msg = """
     Arguments:
@@ -74,7 +75,7 @@ def usage():
 
 def install_version(
     cmdstan_version: str, overwrite: bool = False, verbose: bool = False
-):
+) -> None:
     """
     Build specified CmdStan version by spawning subprocesses to
     run the Make utility on the downloaded CmdStan release src files.
@@ -104,9 +105,10 @@ def install_version(
                 env=os.environ,
             )
             while proc.poll() is None:
-                output = proc.stdout.readline().decode('utf-8').strip()
-                if verbose and output:
-                    print(output, flush=True)
+                if proc.stdout:
+                    output = proc.stdout.readline().decode('utf-8').strip()
+                    if verbose and output:
+                        print(output, flush=True)
             _, stderr = proc.communicate()
             if proc.returncode:
                 msgs = ['Command "make clean-all" failed']
@@ -124,9 +126,11 @@ def install_version(
             env=os.environ,
         )
         while proc.poll() is None:
-            output = proc.stdout.readline().decode('utf-8').strip()
-            if verbose and output:
-                print(output, flush=True)
+            if proc.stdout:
+
+                output = proc.stdout.readline().decode('utf-8').strip()
+                if verbose and output:
+                    print(output, flush=True)
         _, stderr = proc.communicate()
         if proc.returncode:
             msgs = ['Command "make build" failed']
@@ -161,7 +165,8 @@ def install_version(
             env=os.environ,
         )
         while proc.poll() is None:
-            proc.stdout.readline().decode('utf-8')
+            if proc.stdout:
+                proc.stdout.readline().decode('utf-8')
         _, stderr = proc.communicate()
         if proc.returncode:
             msgs = ['Failed to compile example model bernoulli.stan']
@@ -171,7 +176,7 @@ def install_version(
     print('Installed {}'.format(cmdstan_version))
 
 
-def is_version_available(version: str):
+def is_version_available(version: str) -> bool:
     is_available = True
     url = (
         'https://github.com/stan-dev/cmdstan/releases/download/'
@@ -200,7 +205,7 @@ def is_version_available(version: str):
     return is_available
 
 
-def get_headers():
+def get_headers() -> Dict[str, str]:
     """Create headers dictionary."""
     headers = {}
     GITHUB_PAT = os.environ.get("GITHUB_PAT")  # pylint:disable=invalid-name
@@ -209,7 +214,7 @@ def get_headers():
     return headers
 
 
-def latest_version():
+def latest_version() -> str:
     """Report latest CmdStan release version."""
     url = 'https://api.github.com/repos/stan-dev/cmdstan/releases/latest'
     request = urllib.request.Request(url, headers=get_headers())
@@ -232,10 +237,10 @@ def latest_version():
     match = re.search(r'v?(.+)', tag)
     if match is not None:
         tag = match.group(1)
-    return tag
+    return tag  # type: ignore
 
 
-def wrap_progress_hook():
+def wrap_progress_hook() -> Optional[Callable[[int, int, int], None]]:
     try:
         from tqdm import tqdm
 
@@ -245,7 +250,9 @@ def wrap_progress_hook():
             unit_divisor=1024,
         )
 
-        def download_progress_hook(count, block_size, total_size):
+        def download_progress_hook(
+            count: int, block_size: int, total_size: int
+        ) -> None:
             if pbar.total is None:
                 pbar.total = total_size
                 pbar.reset()
@@ -256,12 +263,12 @@ def wrap_progress_hook():
 
     except (ImportError, ModuleNotFoundError):
         print("tqdm is not installed, progressbar not shown")
-        download_progress_hook = None
+        return None
 
     return download_progress_hook
 
 
-def retrieve_version(version: str, progress=True):
+def retrieve_version(version: str, progress: bool = True) -> None:
     """Download specified CmdStan version."""
     if version is None or version == '':
         raise ValueError('Argument "version" unspecified.')
@@ -273,7 +280,9 @@ def retrieve_version(version: str, progress=True):
     for i in range(6):  # always retry to allow for transient URLErrors
         try:
             if progress:
-                progress_hook = wrap_progress_hook()
+                progress_hook: Optional[
+                    Callable[[int, int, int], None]
+                ] = wrap_progress_hook()
             else:
                 progress_hook = None
             file_tmp, _ = urllib.request.urlretrieve(
@@ -319,7 +328,7 @@ def retrieve_version(version: str, progress=True):
     print('Unpacked download as cmdstan-{}'.format(version))
 
 
-def main():
+def main() -> None:
     """Main."""
     parser = argparse.ArgumentParser()
     parser.add_argument(
diff --git a/cmdstanpy/install_cxx_toolchain.py b/cmdstanpy/install_cxx_toolchain.py
index 8b9bb2f..ac41030 100644
--- a/cmdstanpy/install_cxx_toolchain.py
+++ b/cmdstanpy/install_cxx_toolchain.py
@@ -22,6 +22,7 @@ import sys
 import urllib.request
 from collections import OrderedDict
 from time import sleep
+from typing import Callable, Iterator, List, Optional
 
 from cmdstanpy import _DOT_CMDSTAN, _DOT_CMDSTANPY
 from cmdstanpy.utils import validate_dir
@@ -31,7 +32,7 @@ IS_64BITS = sys.maxsize > 2 ** 32
 
 
 @contextlib.contextmanager
-def pushd(new_dir):
+def pushd(new_dir: str) -> Iterator[None]:
     """Acts like pushd/popd."""
     previous_dir = os.getcwd()
     os.chdir(new_dir)
@@ -39,7 +40,7 @@ def pushd(new_dir):
     os.chdir(previous_dir)
 
 
-def usage():
+def usage() -> None:
     """Print usage."""
     print(
         """Arguments:
@@ -53,7 +54,7 @@ def usage():
     )
 
 
-def get_config(dir, silent):
+def get_config(dir: str, silent: bool) -> List[str]:
     """Assemble config info."""
     config = []
     if platform.system() == 'Windows':
@@ -74,8 +75,12 @@ def get_config(dir, silent):
 
 
 def install_version(
-    installation_dir, installation_file, version, silent, verbose=False
-):
+    installation_dir: str,
+    installation_file: str,
+    version: str,
+    silent: bool,
+    verbose: bool = False,
+) -> None:
     """Install specified toolchain version."""
     with pushd('.'):
         print(
@@ -95,9 +100,10 @@ def install_version(
             env=os.environ,
         )
         while proc.poll() is None:
-            output = proc.stdout.readline().decode('utf-8').strip()
-            if output and verbose:
-                print(output, flush=True)
+            if proc.stdout:
+                output = proc.stdout.readline().decode('utf-8').strip()
+                if output and verbose:
+                    print(output, flush=True)
         _, stderr = proc.communicate()
         if proc.returncode:
             print('Installation failed: returncode={}'.format(proc.returncode))
@@ -112,7 +118,7 @@ def install_version(
     print('Installed {}'.format(os.path.splitext(installation_file)[0]))
 
 
-def install_mingw32_make(toolchain_loc, verbose=False):
+def install_mingw32_make(toolchain_loc: str, verbose: bool = False) -> None:
     """Install mingw32-make for Windows RTools 4.0."""
     os.environ['PATH'] = ';'.join(
         list(
@@ -146,9 +152,10 @@ def install_mingw32_make(toolchain_loc, verbose=False):
             env=os.environ,
         )
         while proc.poll() is None:
-            output = proc.stdout.readline().decode('utf-8').strip()
-            if output and verbose:
-                print(output, flush=True)
+            if proc.stdout:
+                output = proc.stdout.readline().decode('utf-8').strip()
+                if output and verbose:
+                    print(output, flush=True)
         _, stderr = proc.communicate()
         if proc.returncode:
             print(
@@ -162,7 +169,7 @@ def install_mingw32_make(toolchain_loc, verbose=False):
     print('Installed mingw32-make.exe')
 
 
-def is_installed(toolchain_loc, version):
+def is_installed(toolchain_loc: str, version: str) -> bool:
     """Returns True is toolchain is installed."""
     if platform.system() == 'Windows':
         if version in ['35', '3.5']:
@@ -190,14 +197,14 @@ def is_installed(toolchain_loc, version):
     return False
 
 
-def latest_version():
+def latest_version() -> str:
     """Windows version hardcoded to 4.0."""
     if platform.system() == 'Windows':
         return '4.0'
     return ''
 
 
-def wrap_progress_hook():
+def wrap_progress_hook() -> Optional[Callable[[int, int, int], None]]:
     try:
         from tqdm import tqdm
 
@@ -207,7 +214,9 @@ def wrap_progress_hook():
             unit_divisor=1024,
         )
 
-        def download_progress_hook(count, block_size, total_size):
+        def download_progress_hook(
+            count: int, block_size: int, total_size: int
+        ) -> None:
             if pbar.total is None:
                 pbar.total = total_size
                 pbar.reset()
@@ -218,12 +227,12 @@ def wrap_progress_hook():
 
     except (ImportError, ModuleNotFoundError):
         print("tqdm was not downloaded, progressbar not shown")
-        download_progress_hook = None
+        return None
 
     return download_progress_hook
 
 
-def retrieve_toolchain(filename, url, progress=True):
+def retrieve_toolchain(filename: str, url: str, progress: bool = True) -> None:
     """Download toolchain from URL."""
     print('Downloading C++ toolchain: {}'.format(filename))
     for i in range(6):
@@ -247,7 +256,7 @@ def retrieve_toolchain(filename, url, progress=True):
     print('Download successful, file: {}'.format(filename))
 
 
-def normalize_version(version):
+def normalize_version(version: str) -> str:
     """Return maj.min part of version string."""
     if platform.system() == 'Windows':
         if version in ['4', '40']:
@@ -257,14 +266,14 @@ def normalize_version(version):
     return version
 
 
-def get_toolchain_name():
+def get_toolchain_name() -> str:
     """Return toolchain name."""
     if platform.system() == 'Windows':
         return 'RTools'
     return ''
 
 
-def get_url(version):
+def get_url(version: str) -> str:
     """Return URL for toolchain."""
     if platform.system() == 'Windows':
         if version == '4.0':
@@ -278,16 +287,16 @@ def get_url(version):
     return url
 
 
-def get_toolchain_version(name, version):
+def get_toolchain_version(name: str, version: str) -> str:
     """Toolchain version."""
-    toolchain_folder = None
+    toolchain_folder = ''
     if platform.system() == 'Windows':
         toolchain_folder = '{}{}'.format(name, version.replace('.', ''))
 
     return toolchain_folder
 
 
-def main():
+def main() -> None:
     """Main."""
     if platform.system() not in {'Windows'}:
         msg = (
diff --git a/cmdstanpy/model.py b/cmdstanpy/model.py
index 70aca0b..957d78d 100644
--- a/cmdstanpy/model.py
+++ b/cmdstanpy/model.py
@@ -9,9 +9,8 @@ import subprocess
 from collections import OrderedDict
 from concurrent.futures import ThreadPoolExecutor
 from multiprocessing import cpu_count
-from numbers import Real
 from pathlib import Path
-from typing import Any, Dict, List, Union
+from typing import Any, Dict, List, Optional, Union
 
 from cmdstanpy.cmdstan_args import (
     CmdStanArgs,
@@ -73,13 +72,13 @@ class CmdStanModel:
 
     def __init__(
         self,
-        model_name: str = None,
-        stan_file: str = None,
-        exe_file: str = None,
+        model_name: Optional[str] = None,
+        stan_file: Optional[str] = None,
+        exe_file: Optional[str] = None,
         compile: bool = True,
-        stanc_options: Dict = None,
-        cpp_options: Dict = None,
-        logger: logging.Logger = None,
+        stanc_options: Optional[Dict[str, Any]] = None,
+        cpp_options: Optional[Dict[str, Any]] = None,
+        logger: Optional[logging.Logger] = None,
     ) -> None:
         """
         Initialize object given constructor args.
@@ -92,7 +91,7 @@ class CmdStanModel:
         :param cpp_options: Options for C++ compiler.
         :param logger: Python logger object.
         """
-        self._name = None
+        self._name = ''
         self._stan_file = None
         self._exe_file = None
         self._compiler_options = CompilerOptions(
@@ -124,7 +123,7 @@ class CmdStanModel:
                 raise ValueError(
                     'invalid stan filename {}'.format(self._stan_file)
                 )
-            if self._name is None:
+            if not self._name:
                 self._name, _ = os.path.splitext(filename)
             # if program has include directives, record path
             with open(self._stan_file, 'r') as fd:
@@ -147,7 +146,7 @@ class CmdStanModel:
             if not os.path.exists(self._exe_file):
                 raise ValueError('no such file {}'.format(self._exe_file))
             _, exename = os.path.split(self._exe_file)
-            if self._name is None:
+            if not self._name:
                 self._name, _ = os.path.splitext(exename)
             else:
                 if self._name != os.path.splitext(exename)[0]:
@@ -157,8 +156,7 @@ class CmdStanModel:
                         ' found: {}.'.format(self._name, exename)
                     )
 
-        if self._compiler_options is not None:
-            self._compiler_options.validate()
+        self._compiler_options.validate()
 
         if platform.system() == 'Windows':
             # Add tbb to the $PATH on Windows
@@ -201,26 +199,26 @@ class CmdStanModel:
         return self._name
 
     @property
-    def stan_file(self) -> str:
+    def stan_file(self) -> Optional[str]:
         """Full path to Stan program file."""
         return self._stan_file
 
     @property
-    def exe_file(self) -> str:
+    def exe_file(self) -> Optional[str]:
         """Full path to Stan exe file."""
         return self._exe_file
 
     @property
-    def stanc_options(self) -> Dict:
+    def stanc_options(self) -> Dict[str, Union[bool, int, str]]:
         """Options to stanc compilers."""
         return self._compiler_options._stanc_options
 
     @property
-    def cpp_options(self) -> Dict:
+    def cpp_options(self) -> Dict[str, Union[bool, int]]:
         """Options to C++ compilers."""
         return self._compiler_options._cpp_options
 
-    def code(self) -> str:
+    def code(self) -> Optional[str]:
         """Return Stan program as a string."""
         if not self._stan_file:
             raise RuntimeError('Please specify source file')
@@ -238,8 +236,8 @@ class CmdStanModel:
     def compile(
         self,
         force: bool = False,
-        stanc_options: Dict = None,
-        cpp_options: Dict = None,
+        stanc_options: Optional[Dict[str, Any]] = None,
+        cpp_options: Optional[Dict[str, Any]] = None,
         override_options: bool = False,
     ) -> None:
         """
@@ -362,22 +360,22 @@ class CmdStanModel:
 
     def optimize(
         self,
-        data: Union[Dict, str] = None,
-        seed: int = None,
-        inits: Union[Dict, float, str] = None,
-        output_dir: str = None,
-        sig_figs: int = None,
+        data: Union[Dict[str, Any], str, None] = None,
+        seed: Optional[int] = None,
+        inits: Union[Dict[str, float], float, str, None] = None,
+        output_dir: Optional[str] = None,
+        sig_figs: Optional[int] = None,
         save_profile: bool = False,
-        algorithm: str = None,
-        init_alpha: float = None,
-        tol_obj: float = None,
-        tol_rel_obj: float = None,
-        tol_grad: float = None,
-        tol_rel_grad: float = None,
-        tol_param: float = None,
-        history_size: int = None,
-        iter: int = None,
-        refresh: int = None,
+        algorithm: Optional[str] = None,
+        init_alpha: Optional[float] = None,
+        tol_obj: Optional[float] = None,
+        tol_rel_obj: Optional[float] = None,
+        tol_grad: Optional[float] = None,
+        tol_rel_grad: Optional[float] = None,
+        tol_param: Optional[float] = None,
+        history_size: Optional[int] = None,
+        iter: Optional[int] = None,
+        refresh: Optional[int] = None,
     ) -> CmdStanMLE:
         """
         Run the specified CmdStan optimize algorithm to produce a
@@ -509,32 +507,32 @@ class CmdStanModel:
     # pylint: disable=too-many-arguments
     def sample(
         self,
-        data: Union[Dict, str] = None,
-        chains: Union[int, None] = None,
-        parallel_chains: Union[int, None] = None,
-        threads_per_chain: Union[int, None] = None,
-        seed: Union[int, List[int]] = None,
-        chain_ids: Union[int, List[int]] = None,
-        inits: Union[Dict, float, str, List[str]] = None,
-        iter_warmup: int = None,
-        iter_sampling: int = None,
+        data: Union[Dict[str, Any], str, None] = None,
+        chains: Optional[int] = None,
+        parallel_chains: Optional[int] = None,
+        threads_per_chain: Optional[int] = None,
+        seed: Union[int, List[int], None] = None,
+        chain_ids: Union[int, List[int], None] = None,
+        inits: Union[Dict[str, float], float, str, List[str], None] = None,
+        iter_warmup: Optional[int] = None,
+        iter_sampling: Optional[int] = None,
         save_warmup: bool = False,
-        thin: int = None,
-        max_treedepth: float = None,
-        metric: Union[str, List[str]] = None,
-        step_size: Union[float, List[float]] = None,
+        thin: Optional[int] = None,
+        max_treedepth: Optional[int] = None,
+        metric: Union[str, List[str], None] = None,
+        step_size: Union[float, List[float], None] = None,
         adapt_engaged: bool = True,
-        adapt_delta: float = None,
-        adapt_init_phase: int = None,
-        adapt_metric_window: int = None,
-        adapt_step_size: int = None,
+        adapt_delta: Optional[float] = None,
+        adapt_init_phase: Optional[int] = None,
+        adapt_metric_window: Optional[int] = None,
+        adapt_step_size: Optional[int] = None,
         fixed_param: bool = False,
-        output_dir: str = None,
-        sig_figs: int = None,
+        output_dir: Optional[str] = None,
+        sig_figs: Optional[int] = None,
         save_diagnostics: bool = False,
         save_profile: bool = False,
         show_progress: Union[bool, str] = False,
-        refresh: int = None,
+        refresh: Optional[int] = None,
     ) -> CmdStanMCMC:
         """
         Run or more chains of the NUTS sampler to produce a set of draws
@@ -835,10 +833,10 @@ class CmdStanModel:
                             tqdm_pbar = tqdm.tqdm
                         # enable dynamic_ncols for advanced users
                         # currently hidden feature
-                        dynamic_ncols = os.environ.get(
+                        dynamic_ncols_raw = os.environ.get(
                             'TQDM_DYNAMIC_NCOLS', 'False'
                         )
-                        if dynamic_ncols.lower() in ['0', 'false']:
+                        if dynamic_ncols_raw.lower() in ['0', 'false']:
                             dynamic_ncols = False
                         else:
                             dynamic_ncols = True
@@ -870,12 +868,12 @@ class CmdStanModel:
 
     def generate_quantities(
         self,
-        data: Union[Dict, str] = None,
-        mcmc_sample: Union[CmdStanMCMC, List[str]] = None,
-        seed: int = None,
-        gq_output_dir: str = None,
-        sig_figs: int = None,
-        refresh: int = None,
+        data: Union[Dict[str, Any], str, None] = None,
+        mcmc_sample: Union[CmdStanMCMC, List[str], None] = None,
+        seed: Optional[int] = None,
+        gq_output_dir: Optional[str] = None,
+        sig_figs: Optional[int] = None,
+        refresh: Optional[int] = None,
     ) -> CmdStanGQ:
         """
         Run CmdStan's generate_quantities method which runs the generated
@@ -1026,25 +1024,25 @@ class CmdStanModel:
 
     def variational(
         self,
-        data: Union[Dict, str] = None,
-        seed: int = None,
-        inits: float = None,
-        output_dir: str = None,
-        sig_figs: int = None,
+        data: Union[Dict[str, Any], str, None] = None,
+        seed: Optional[int] = None,
+        inits: Optional[float] = None,
+        output_dir: Optional[str] = None,
+        sig_figs: Optional[int] = None,
         save_diagnostics: bool = False,
         save_profile: bool = False,
-        algorithm: str = None,
-        iter: int = None,
-        grad_samples: int = None,
-        elbo_samples: int = None,
-        eta: Real = None,
+        algorithm: Optional[str] = None,
+        iter: Optional[int] = None,
+        grad_samples: Optional[int] = None,
+        elbo_samples: Optional[int] = None,
+        eta: Optional[float] = None,
         adapt_engaged: bool = True,
-        adapt_iter: int = None,
-        tol_rel_obj: Real = None,
-        eval_elbo: int = None,
-        output_samples: int = None,
+        adapt_iter: Optional[int] = None,
+        tol_rel_obj: Optional[float] = None,
+        eval_elbo: Optional[int] = None,
+        output_samples: Optional[int] = None,
         require_converged: bool = True,
-        refresh: int = None,
+        refresh: Optional[int] = None,
     ) -> CmdStanVB:
         """
         Run CmdStan's variational inference algorithm to approximate
@@ -1244,7 +1242,10 @@ class CmdStanModel:
             raise RuntimeError(msg) from e
 
     def _read_progress(
-        self, proc: subprocess.Popen, pbar: Any, idx: int
+        self,
+        proc: subprocess.Popen,  # [] - Popoen is only generic in 3.9
+        pbar: Any,
+        idx: int,
     ) -> bytes:
         """
         Update tqdm progress bars according to CmdStan console progress msgs.
@@ -1263,7 +1264,7 @@ class CmdStanModel:
 
         try:
             # iterate while process is sampling
-            while proc.poll() is None:
+            while proc.poll() is None and proc.stdout is not None:
                 output = proc.stdout.readline()
                 stdout += output
                 output = output.decode('utf-8').strip()
diff --git a/cmdstanpy/stanfit.py b/cmdstanpy/stanfit.py
index 61b4bff..adf7683 100644
--- a/cmdstanpy/stanfit.py
+++ b/cmdstanpy/stanfit.py
@@ -10,7 +10,16 @@ import shutil
 from collections import Counter, OrderedDict
 from datetime import datetime
 from time import time
-from typing import Dict, List, Tuple, Union
+from typing import (
+    Any,
+    Dict,
+    Hashable,
+    List,
+    MutableMapping,
+    Optional,
+    Tuple,
+    Union,
+)
 
 import numpy as np
 import pandas as pd
@@ -58,8 +67,8 @@ class RunSet:
         self,
         args: CmdStanArgs,
         chains: int = 4,
-        chain_ids: List[int] = None,
-        logger: logging.Logger = None,
+        chain_ids: Optional[List[int]] = None,
+        logger: Optional[logging.Logger] = None,
     ) -> None:
         """Initialize object."""
         self._args = args
@@ -92,11 +101,11 @@ class RunSet:
             output_dir = args.output_dir
         else:
             output_dir = _TMPDIR
-        self._csv_files = [None for _ in range(chains)]
-        self._diagnostic_files = [None for _ in range(chains)]
-        self._profile_files = [None for _ in range(chains)]
-        self._stdout_files = [None for _ in range(chains)]
-        self._stderr_files = [None for _ in range(chains)]
+        self._csv_files = ['' for _ in range(chains)]
+        self._diagnostic_files = ['' for _ in range(chains)]
+        self._profile_files = ['' for _ in range(chains)]
+        self._stdout_files = ['' for _ in range(chains)]
+        self._stderr_files = ['' for _ in range(chains)]
         self._cmds = []
         for i in range(chains):
             if args.output_dir is None:
@@ -231,7 +240,7 @@ class RunSet:
         return self._chain_ids
 
     @property
-    def cmds(self) -> List[str]:
+    def cmds(self) -> List[List[str]]:
         """List of call(s) to CmdStan, one call per-chain."""
         return self._cmds
 
@@ -275,7 +284,7 @@ class RunSet:
         """Set retcode for chain[idx] to val."""
         self._retcodes[idx] = val
 
-    def get_err_msgs(self) -> List[str]:
+    def get_err_msgs(self) -> str:
         """Checks console messages for each chain."""
         msgs = []
         for i in range(self._chains):
@@ -308,7 +317,7 @@ class RunSet:
                     )
         return '\n'.join(msgs)
 
-    def save_csvfiles(self, dir: str = None) -> None:
+    def save_csvfiles(self, dir: Optional[str] = None) -> None:
         """
         Moves csvfiles to specified directory.
 
@@ -362,7 +371,7 @@ class InferenceMetadata:
     Assumes valid CSV files.
     """
 
-    def __init__(self, config: Dict) -> None:
+    def __init__(self, config: Dict[str, Any]) -> None:
         """Initialize object from CSV headers"""
         self._cmdstan_config = config
         self._method_vars_cols = parse_method_vars(names=config['column_names'])
@@ -376,7 +385,7 @@ class InferenceMetadata:
         return 'Metadata:\n{}\n'.format(self._cmdstan_config)
 
     @property
-    def cmdstan_config(self) -> Dict:
+    def cmdstan_config(self) -> Dict[str, Any]:
         return copy.deepcopy(self._cmdstan_config)
 
     @property
@@ -429,7 +438,7 @@ class CmdStanMCMC:
     def __init__(
         self,
         runset: RunSet,
-        logger: logging.Logger = None,
+        logger: Optional[logging.Logger] = None,
     ) -> None:
         """Initialize object."""
         if not runset.method == Method.SAMPLE:
@@ -440,26 +449,36 @@ class CmdStanMCMC:
         self.runset = runset
         self._logger = logger or get_logger()
         # info from runset to be exposed
-        self._iter_sampling = runset._args.method_args.iter_sampling
-        if self._iter_sampling is None:
+        sampler_args = self.runset._args.method_args
+        assert isinstance(
+            sampler_args, SamplerArgs
+        )  # make the typechecker happy
+        iter_sampling = sampler_args.iter_sampling
+        if iter_sampling is None:
             self._iter_sampling = _CMDSTAN_SAMPLING
-        self._iter_warmup = runset._args.method_args.iter_warmup
-        if self._iter_warmup is None:
+        else:
+            self._iter_sampling = iter_sampling
+        iter_warmup = sampler_args.iter_warmup
+        if iter_warmup is None:
             self._iter_warmup = _CMDSTAN_WARMUP
-        self._thin = runset._args.method_args.thin
-        if self._thin is None:
-            self._thin = _CMDSTAN_THIN
-        self._is_fixed_param = runset._args.method_args.fixed_param
-        self._save_warmup = runset._args.method_args.save_warmup
+        else:
+            self._iter_warmup = iter_warmup
+        thin = sampler_args.thin
+        if thin is None:
+            self._thin: int = _CMDSTAN_THIN
+        else:
+            self._thin = thin
+        self._is_fixed_param = sampler_args.fixed_param
+        self._save_warmup = sampler_args.save_warmup
         self._sig_figs = runset._args.sig_figs
         # info from CSV values, instantiated lazily
-        self._metric = None
-        self._step_size = None
-        self._draws = None
-        self._draws_pd = None
+        self._metric = np.array(())
+        self._step_size = np.array(())
+        self._draws = np.array(())
+        self._draws_pd = pd.DataFrame()
         # info from CSV initial comments and header
         config = self._validate_csv_files()
-        self._metadata = InferenceMetadata(config)
+        self._metadata: InferenceMetadata = InferenceMetadata(config)
 
     def __repr__(self) -> str:
         repr = 'CmdStanMCMC: model={} chains={}{}'.format(
@@ -508,7 +527,7 @@ class CmdStanMCMC:
         return self._metadata
 
     @property
-    def sampler_vars_cols(self) -> Dict:
+    def sampler_vars_cols(self) -> Dict[str, Tuple[int, ...]]:
         """
         Deprecated - use "metadata.method_vars_cols" instead
         """
@@ -519,7 +538,7 @@ class CmdStanMCMC:
         return self.metadata.method_vars_cols
 
     @property
-    def stan_vars_cols(self) -> Dict:
+    def stan_vars_cols(self) -> Dict[str, Tuple[int, ...]]:
         """
         Deprecated - use "metadata.stan_vars_cols" instead
         """
@@ -530,7 +549,7 @@ class CmdStanMCMC:
         return self.metadata.method_vars_cols
 
     @property
-    def stan_vars_dims(self) -> Dict:
+    def stan_vars_dims(self) -> Dict[str, Tuple[int, ...]]:
         """
         Deprecated - use "metadata.stan_vars_dims" instead
         """
@@ -548,7 +567,7 @@ class CmdStanMCMC:
         and quantities of interest. Corresponds to Stan CSV file header row,
         with names munged to array notation, e.g. `beta[1]` not `beta.1`.
         """
-        return self._metadata.cmdstan_config['column_names']
+        return self._metadata.cmdstan_config['column_names']  # type: ignore
 
     @property
     def num_unconstrained_params(self) -> int:
@@ -566,39 +585,42 @@ class CmdStanMCMC:
         however a model with variables ``real alpha`` and ``simplex[3] beta``
         has 4 constrained and 3 unconstrained parameters.
         """
-        return self._metadata.cmdstan_config['num_unconstrained_params']
+        return self._metadata.cmdstan_config[  # type: ignore
+            'num_unconstrained_params'
+        ]
 
     @property
-    def metric_type(self) -> str:
+    def metric_type(self) -> Optional[str]:
         """
         Metric type used for adaptation, either 'diag_e' or 'dense_e'.
         When sampler algorithm 'fixed_param' is specified, metric_type is None.
         """
         if self._is_fixed_param:
             return None
-        return self._metadata.cmdstan_config['metric']  # cmdstan arg name
+        # cmdstan arg name
+        return self._metadata.cmdstan_config['metric']  # type: ignore
 
     @property
-    def metric(self) -> np.ndarray:
+    def metric(self) -> Optional[np.ndarray]:
         """
         Metric used by sampler for each chain.
         When sampler algorithm 'fixed_param' is specified, metric is None.
         """
         if self._is_fixed_param:
             return None
-        if self._metric is None:
+        if self._metric.shape == (0,):
             self._assemble_draws()
         return self._metric
 
     @property
-    def step_size(self) -> np.ndarray:
+    def step_size(self) -> Optional[np.ndarray]:
         """
         Step size used by sampler for each chain.
         When sampler algorithm 'fixed_param' is specified, step size is None.
         """
         if self._is_fixed_param:
             return None
-        if self._step_size is None:
+        if self._step_size.shape == (0,):
             self._assemble_draws()
         return self._step_size
 
@@ -629,7 +651,7 @@ class CmdStanMCMC:
         :param concat_chains: When ``True`` return a 2D array flattening all
             all draws from all chains.  Default value is ``False``.
         """
-        if self._draws is None:
+        if self._draws.shape == (0,):
             self._assemble_draws()
 
         if inc_warmup and not self._save_warmup:
@@ -646,10 +668,10 @@ class CmdStanMCMC:
 
         if concat_chains:
             num_rows *= self.chains
-            return self._draws[start_idx:, :, :].reshape(
+            return self._draws[start_idx:, :, :].reshape(  # type: ignore
                 (num_rows, len(self.column_names)), order='F'
             )
-        return self._draws[start_idx:, :, :]
+        return self._draws[start_idx:, :, :]  # type: ignore
 
     @property
     def sample(self) -> np.ndarray:
@@ -673,7 +695,7 @@ class CmdStanMCMC:
         )
         return self.draws(inc_warmup=True)
 
-    def _validate_csv_files(self) -> dict:
+    def _validate_csv_files(self) -> Dict[str, Any]:
         """
         Checks that Stan CSV output files for all chains are consistent
         and returns dict containing config and column names.
@@ -731,7 +753,7 @@ class CmdStanMCMC:
         Allocates and populates the step size, metric, and sample arrays
         by parsing the validated stan_csv files.
         """
-        if self._draws is not None:
+        if self._draws.shape != (0,):
             return
 
         num_draws = self.num_draws_sampling
@@ -796,9 +818,12 @@ class CmdStanMCMC:
                     line = fd.readline().strip()
                     xs = line.split(',')
                     self._draws[i, chain, :] = [float(x) for x in xs]
+        assert self._draws is not None
 
     def summary(
-        self, percentiles: List[int] = None, sig_figs: int = None
+        self,
+        percentiles: Optional[List[int]] = None,
+        sig_figs: Optional[int] = None,
     ) -> pd.DataFrame:
         """
         Run cmdstan/bin/stansummary over all output csv files, assemble
@@ -883,7 +908,7 @@ class CmdStanMCMC:
         mask = [x == 'lp__' or not x.endswith('__') for x in summary_data.index]
         return summary_data[mask]
 
-    def diagnose(self) -> str:
+    def diagnose(self) -> Optional[str]:
         """
         Run cmdstan/bin/diagnose over all output csv files.
         Returns output of diagnose (stdout/stderr).
@@ -905,7 +930,7 @@ class CmdStanMCMC:
         return result
 
     def draws_pd(
-        self, params: List[str] = None, inc_warmup: bool = False
+        self, params: Optional[List[str]] = None, inc_warmup: bool = False
     ) -> pd.DataFrame:
         """
         Returns the sampler draws as a pandas DataFrame.  Flattens all
@@ -940,7 +965,7 @@ class CmdStanMCMC:
         if inc_warmup and self._save_warmup:
             num_draws += self.num_draws_warmup
         num_rows = num_draws * self.chains
-        if self._draws_pd is None or self._draws_pd.shape[0] != num_rows:
+        if self._draws_pd.shape[0] != num_rows:
             # pylint: disable=redundant-keyword-arg
             data = self.draws(inc_warmup=inc_warmup).reshape(
                 (num_rows, len(self.column_names)), order='F'
@@ -951,11 +976,11 @@ class CmdStanMCMC:
         return self._draws_pd[mask]
 
     def draws_xr(
-        self, vars: List[str] = None, inc_warmup: bool = False
+        self, vars_in: Optional[List[str]] = None, inc_warmup: bool = False
     ) -> "xr.Dataset":
         """
         Returns the sampler draws as a xarray Dataset.
-        :param vars: optional list of variable names.
+        :param vars_in: optional list of variable names.
         :param inc_warmup: When ``True`` and the warmup draws are present in
             the output, i.e., the sampler was run with ``save_warmup=True``,
             then the warmup draws are included.  Default value is ``False``.
@@ -970,14 +995,16 @@ class CmdStanMCMC:
                 ' must run sampler with "save_warmup=True".'
             )
 
-        if vars is None:
-            vars = self.stan_vars_dims.keys()
+        if vars_in is None:
+            vars = list(self.stan_vars_dims.keys())
+        else:
+            vars = vars_in
 
         self._assemble_draws()
 
         num_draws = self.num_draws_sampling
         meta = self._metadata.cmdstan_config
-        attrs = {
+        attrs: MutableMapping[Hashable, Any] = {
             "stan_version": f"{meta['stan_version_major']}."
             f"{meta['stan_version_minor']}.{meta['stan_version_patch']}",
             "model": meta["model"],
@@ -988,8 +1015,11 @@ class CmdStanMCMC:
             num_draws += self.num_draws_warmup
             attrs["num_draws_warmup"] = self.num_draws_warmup
 
-        data = {}
-        coordinates = {"chain": self.chain_ids, "draw": np.arange(num_draws)}
+        data: MutableMapping[Hashable, Any] = {}
+        coordinates: MutableMapping[Hashable, Any] = {
+            "chain": self.chain_ids,
+            "draw": np.arange(num_draws),
+        }
         dims = ("draw", "chain")
         for var in vars:
             draw1 = 0
@@ -1059,7 +1089,9 @@ class CmdStanMCMC:
         if len(col_idxs) > 0:
             dims.extend(self._metadata.stan_vars_dims[name])
         # pylint: disable=redundant-keyword-arg
-        return self._draws[draw1:, :, col_idxs].reshape(dims, order='F')
+        return self._draws[draw1:, :, col_idxs].reshape(  # type: ignore
+            dims, order='F'
+        )
 
     def stan_variables(self) -> Dict[str, np.ndarray]:
         """
@@ -1071,7 +1103,7 @@ class CmdStanMCMC:
             result[name] = self.stan_variable(name)
         return result
 
-    def method_variables(self) -> Dict:
+    def method_variables(self) -> Dict[str, np.ndarray]:
         """
         Returns a dictionary of all sampler variables, i.e., all
         output column names ending in `__`.  Assumes that all variables
@@ -1086,7 +1118,7 @@ class CmdStanMCMC:
                 result[self.column_names[idx]] = self._draws[:, :, idx]
         return result
 
-    def sampler_variables(self) -> Dict:
+    def sampler_variables(self) -> Dict[str, np.ndarray]:
         """
         Deprecated, use "method_variables" instead
         """
@@ -1096,7 +1128,7 @@ class CmdStanMCMC:
         )
         return self.method_variables()
 
-    def sampler_diagnostics(self) -> Dict:
+    def sampler_diagnostics(self) -> Dict[str, np.ndarray]:
         """
         Deprecated, use "method_variables" instead
         """
@@ -1106,7 +1138,7 @@ class CmdStanMCMC:
         )
         return self.method_variables()
 
-    def save_csvfiles(self, dir: str = None) -> None:
+    def save_csvfiles(self, dir: Optional[str] = None) -> None:
         """
         Move output csvfiles to specified directory.  If files were
         written to the temporary session directory, clean filename.
@@ -1131,9 +1163,6 @@ class CmdStanMLE:
                 'found method {}'.format(runset.method)
             )
         self.runset = runset
-        self._metadata = None
-        self._column_names = ()
-        self._mle = {}
         self._set_mle_attrs(runset.csv_files[0])
 
     def __repr__(self) -> str:
@@ -1151,8 +1180,8 @@ class CmdStanMLE:
     def _set_mle_attrs(self, sample_csv_0: str) -> None:
         meta = scan_optimize_csv(sample_csv_0)
         self._metadata = InferenceMetadata(meta)
-        self._column_names = meta['column_names']
-        self._mle = meta['mle']
+        self._column_names: Tuple[str, ...] = meta['column_names']
+        self._mle: List[float] = meta['mle']
 
     @property
     def column_names(self) -> Tuple[str, ...]:
@@ -1182,7 +1211,7 @@ class CmdStanMLE:
         return pd.DataFrame([self._mle], columns=self.column_names)
 
     @property
-    def optimized_params_dict(self) -> OrderedDict:
+    def optimized_params_dict(self) -> Dict[str, float]:
         """Returns optimized params as Dict."""
         return OrderedDict(zip(self.column_names, self._mle))
 
@@ -1199,7 +1228,7 @@ class CmdStanMLE:
         col_idxs = list(self._metadata.stan_vars_cols[name])
         vals = list(self._mle)
         xs = [vals[x] for x in col_idxs]
-        shape = ()
+        shape: Tuple[int, ...] = ()
         if len(col_idxs) > 0:
             shape = self._metadata.stan_vars_dims[name]
         return np.array(xs).reshape(shape)
@@ -1214,7 +1243,7 @@ class CmdStanMLE:
             result[name] = self.stan_variable(name)
         return result
 
-    def save_csvfiles(self, dir: str = None) -> None:
+    def save_csvfiles(self, dir: Optional[str] = None) -> None:
         """
         Move output csvfiles to specified directory.  If files were
         written to the temporary session directory, clean filename.
@@ -1242,7 +1271,7 @@ class CmdStanGQ:
         self._metadata = None
         self.mcmc_sample = mcmc_sample
         self._generated_quantities = None
-        self._column_names = scan_generated_quantities_csv(
+        self._column_names: Tuple[str, ...] = scan_generated_quantities_csv(
             self.runset.csv_files[0]
         )['column_names']
 
@@ -1285,7 +1314,7 @@ class CmdStanGQ:
             raise ValueError('Bad runset method {}.'.format(self.runset.method))
         if self._generated_quantities is None:
             self._assemble_generated_quantities()
-        return self._generated_quantities
+        return self._generated_quantities  # type: ignore # fixed by assemble
 
     @property
     def generated_quantities_pd(self) -> pd.DataFrame:
@@ -1318,7 +1347,7 @@ class CmdStanGQ:
         cols_1 = self.mcmc_sample.columns.tolist()
         cols_2 = self.generated_quantities_pd.columns.tolist()
 
-        dups = [
+        dups = [  # type: ignore
             item
             for item, count in Counter(cols_1 + cols_2).items()
             if count > 1
@@ -1341,7 +1370,7 @@ class CmdStanGQ:
             )
         self._generated_quantities = pd.concat(drawset_list).values
 
-    def save_csvfiles(self, dir: str = None) -> None:
+    def save_csvfiles(self, dir: Optional[str] = None) -> None:
         """
         Move output csvfiles to specified directory.  If files were
         written to the temporary session directory, clean filename.
@@ -1366,10 +1395,6 @@ class CmdStanVB:
                 'found method {}'.format(runset.method)
             )
         self.runset = runset
-        self._metadata = None
-        self._column_names = ()
-        self._variational_mean = {}
-        self._variational_sample = None
         self._set_variational_attrs(runset.csv_files[0])
 
     def __repr__(self) -> str:
@@ -1387,9 +1412,10 @@ class CmdStanVB:
     def _set_variational_attrs(self, sample_csv_0: str) -> None:
         meta = scan_variational_csv(sample_csv_0)
         self._metadata = InferenceMetadata(meta)
-        self._column_names = meta['column_names']
-        self._variational_mean = meta['variational_mean']
-        self._variational_sample = meta['variational_sample']
+        # these three assignments don't grant type information
+        self._column_names: Tuple[str, ...] = meta['column_names']
+        self._variational_mean: np.ndarray = meta['variational_mean']
+        self._variational_sample: np.ndarray = meta['variational_sample']
 
     @property
     def columns(self) -> int:
@@ -1420,7 +1446,7 @@ class CmdStanVB:
         return pd.DataFrame([self._variational_mean], columns=self.column_names)
 
     @property
-    def variational_params_dict(self) -> OrderedDict:
+    def variational_params_dict(self) -> Dict[str, np.ndarray]:
         """Returns inferred parameter means as Dict."""
         return OrderedDict(zip(self.column_names, self._variational_mean))
 
@@ -1446,7 +1472,7 @@ class CmdStanVB:
         col_idxs = list(self._metadata.stan_vars_cols[name])
         vals = list(self._variational_mean)
         xs = [vals[x] for x in col_idxs]
-        shape = ()
+        shape: Tuple[int, ...] = ()
         if len(col_idxs) > 0:
             shape = self._metadata.stan_vars_dims[name]
         return np.array(xs).reshape(shape)
@@ -1466,7 +1492,7 @@ class CmdStanVB:
         """Returns the set of approximate posterior output draws."""
         return self._variational_sample
 
-    def save_csvfiles(self, dir: str = None) -> None:
+    def save_csvfiles(self, dir: Optional[str] = None) -> None:
         """
         Move output csvfiles to specified directory.  If files were
         written to the temporary session directory, clean filename.
@@ -1479,8 +1505,8 @@ class CmdStanVB:
 
 
 def from_csv(
-    path: Union[str, List[str]] = None, method: str = None
-) -> Union[CmdStanMCMC, CmdStanMLE, CmdStanVB]:
+    path: Union[str, List[str], None] = None, method: Optional[str] = None
+) -> Union[CmdStanMCMC, CmdStanMLE, CmdStanVB, None]:
     """
     Instantiate a CmdStan object from a the Stan CSV files from a CmdStan run.
     CSV files are specified from either a list of Stan CSV files or a single
@@ -1541,7 +1567,7 @@ def from_csv(
                 ' includes non-csv file: {}'.format(file)
             )
 
-    config_dict = {}
+    config_dict: Dict[str, Any] = {}
     try:
         with open(csvfiles[0], 'r') as fd:
             scan_config(fd, config_dict, 0)
@@ -1556,7 +1582,6 @@ def from_csv(
                 method, config_dict['method']
             )
         )
-    fit = None
     try:
         if config_dict['method'] == 'sample':
             chains = len(csvfiles)
@@ -1578,6 +1603,7 @@ def from_csv(
                 runset._set_retcode(i, 0)
             fit = CmdStanMCMC(runset)
             fit.draws()
+            return fit
         elif config_dict['method'] == 'optimize':
             if 'algorithm' not in config_dict:
                 raise ValueError(
@@ -1597,7 +1623,7 @@ def from_csv(
             runset._csv_files = csvfiles
             for i in range(len(runset._retcodes)):
                 runset._set_retcode(i, 0)
-            fit = CmdStanMLE(runset)
+            return CmdStanMLE(runset)
         elif config_dict['method'] == 'variational':
             if 'algorithm' not in config_dict:
                 raise ValueError(
@@ -1624,14 +1650,14 @@ def from_csv(
             runset._csv_files = csvfiles
             for i in range(len(runset._retcodes)):
                 runset._set_retcode(i, 0)
-            fit = CmdStanVB(runset)
+            return CmdStanVB(runset)
         else:
             get_logger().info(
                 'Unable to process CSV output files from method %s.',
                 (config_dict['method']),
             )
+            return None
     except (IOError, OSError, PermissionError) as e:
         raise ValueError(
             'An error occured processing the CSV files:\n\t{}'.format(str(e))
         ) from e
-    return fit
diff --git a/cmdstanpy/utils.py b/cmdstanpy/utils.py
index bed3d05..a49a7bb 100644
--- a/cmdstanpy/utils.py
+++ b/cmdstanpy/utils.py
@@ -11,9 +11,17 @@ import subprocess
 import sys
 import tempfile
 from collections import OrderedDict
-from collections.abc import Sequence, Collection
-from numbers import Integral, Real
-from typing import Dict, List, TextIO, Tuple, Union
+from collections.abc import Collection, Sequence
+from typing import (
+    Any,
+    Dict,
+    List,
+    MutableMapping,
+    Optional,
+    TextIO,
+    Tuple,
+    Union,
+)
 
 import numpy as np
 import pandas as pd
@@ -31,7 +39,7 @@ from cmdstanpy import (
 EXTENSION = '.exe' if platform.system() == 'Windows' else ''
 
 
-def get_logger():
+def get_logger() -> logging.Logger:
     """cmdstanpy logger"""
     logger = logging.getLogger('cmdstanpy')
     if len(logger.handlers) == 0:
@@ -39,7 +47,7 @@ def get_logger():
     return logger
 
 
-def validate_dir(install_dir: str):
+def validate_dir(install_dir: str) -> None:
     """Check that specified install directory exists, can write."""
     if not os.path.exists(install_dir):
         try:
@@ -63,7 +71,7 @@ def validate_dir(install_dir: str):
             ) from e
 
 
-def get_latest_cmdstan(cmdstan_dir: str) -> str:
+def get_latest_cmdstan(cmdstan_dir: str) -> Optional[str]:
     """
     Given a valid directory path, find all installed CmdStan versions
     and return highest (i.e., latest) version number.
@@ -203,7 +211,7 @@ def cmdstan_version_at(maj: int, min: int) -> bool:
     return False
 
 
-def cxx_toolchain_path(version: str = None) -> Tuple[str, ...]:
+def cxx_toolchain_path(version: Optional[str] = None) -> Tuple[str, ...]:
     """
     Validate, then activate C++ toolchain directory path.
     """
@@ -231,14 +239,14 @@ def cxx_toolchain_path(version: str = None) -> Tuple[str, ...]:
                         'Found invalid installion for RTools40 on %s',
                         toolchain_root,
                     )
-                    toolchain_root = None
+                    toolchain_root = ''
             else:
                 compiler_path = ''
                 logger.warning(
                     'Found invalid installion for RTools40 on %s',
                     toolchain_root,
                 )
-                toolchain_root = None
+                toolchain_root = ''
 
         elif os.path.exists(os.path.join(toolchain_root, 'mingw_64')):
             compiler_path = os.path.join(
@@ -255,14 +263,14 @@ def cxx_toolchain_path(version: str = None) -> Tuple[str, ...]:
                         'Found invalid installion for RTools35 on %s',
                         toolchain_root,
                     )
-                    toolchain_root = None
+                    toolchain_root = ''
             else:
                 compiler_path = ''
                 logger.warning(
                     'Found invalid installion for RTools35 on %s',
                     toolchain_root,
                 )
-                toolchain_root = None
+                toolchain_root = ''
     else:
         rtools40_home = os.environ.get('RTOOLS40_HOME')
         cmdstan_dir = os.path.expanduser(os.path.join('~', _DOT_CMDSTAN))
@@ -300,7 +308,7 @@ def cxx_toolchain_path(version: str = None) -> Tuple[str, ...]:
                                 'Found invalid installation for RTools40 on %s',
                                 toolchain_root,
                             )
-                            toolchain_root = None
+                            toolchain_root = ''
                         else:
                             break
                     else:
@@ -309,7 +317,7 @@ def cxx_toolchain_path(version: str = None) -> Tuple[str, ...]:
                             'Found invalid installation for RTools40 on %s',
                             toolchain_root,
                         )
-                        toolchain_root = None
+                        toolchain_root = ''
                 else:
                     compiler_path = os.path.join(
                         toolchain_root,
@@ -325,7 +333,7 @@ def cxx_toolchain_path(version: str = None) -> Tuple[str, ...]:
                                 'Found invalid installation for RTools35 on %s',
                                 toolchain_root,
                             )
-                            toolchain_root = None
+                            toolchain_root = ''
                         else:
                             break
                     else:
@@ -334,9 +342,9 @@ def cxx_toolchain_path(version: str = None) -> Tuple[str, ...]:
                             'Found invalid installation for RTools35 on %s',
                             toolchain_root,
                         )
-                        toolchain_root = None
+                        toolchain_root = ''
             else:
-                toolchain_root = None
+                toolchain_root = ''
 
     if not toolchain_root:
         raise ValueError(
@@ -366,7 +374,7 @@ def _rdump_array(key: str, val: np.ndarray) -> str:
         return struct
 
 
-def jsondump(path: str, data: Dict) -> None:
+def jsondump(path: str, data: Dict[str, Any]) -> None:
     """Dump a dict of data to a JSON file."""
     data = data.copy()
     for key, val in data.items():
@@ -379,7 +387,7 @@ def jsondump(path: str, data: Dict) -> None:
         json.dump(data, fd)
 
 
-def rdump(path: str, data: Dict) -> None:
+def rdump(path: str, data: Dict[str, Any]) -> None:
     """Dump a dict of data to a R dump format file."""
     with open(path, 'w') as fd:
         for key, val in data.items():
@@ -390,7 +398,7 @@ def rdump(path: str, data: Dict) -> None:
             print(line, file=fd)
 
 
-def rload(fname: str) -> Dict:
+def rload(fname: str) -> Optional[Dict[str, Union[int, float, np.ndarray]]]:
     """Parse data and parameter variable values from an R dump format file.
     This parser only supports the subset of R dump data as described
     in the "Dump Data Format" section of the CmdStan manual, i.e.,
@@ -423,7 +431,7 @@ def rload(fname: str) -> Dict:
     return data_dict
 
 
-def parse_rdump_value(rhs: str) -> Union[int, float, np.array]:
+def parse_rdump_value(rhs: str) -> Union[int, float, np.ndarray]:
     """Process right hand side of Rdump variable assignment statement.
     Value is either scalar, vector, or multi-dim structure.
     Use regex to capture structure values, dimensions.
@@ -432,7 +440,7 @@ def parse_rdump_value(rhs: str) -> Union[int, float, np.array]:
         r'structure\(\s*c\((?P<vals>[^)]*)\)'
         r'(,\s*\.Dim\s*=\s*c\s*\((?P<dims>[^)]*)\s*\))?\)'
     )
-    val = None
+    val: Union[int, float, np.ndarray]
     try:
         if rhs.startswith('structure'):
             parse = pat.match(rhs)
@@ -457,11 +465,11 @@ def parse_rdump_value(rhs: str) -> Union[int, float, np.array]:
 def check_sampler_csv(
     path: str,
     is_fixed_param: bool = False,
-    iter_sampling: int = None,
-    iter_warmup: int = None,
+    iter_sampling: Optional[int] = None,
+    iter_warmup: Optional[int] = None,
     save_warmup: bool = False,
-    thin: int = None,
-) -> Dict:
+    thin: Optional[int] = None,
+) -> Dict[str, Any]:
     """Capture essential config, shape from stan_csv file."""
     meta = scan_sampler_csv(path, is_fixed_param)
     if thin is None:
@@ -509,9 +517,9 @@ def check_sampler_csv(
     return meta
 
 
-def scan_sampler_csv(path: str, is_fixed_param: bool = False) -> Dict:
+def scan_sampler_csv(path: str, is_fixed_param: bool = False) -> Dict[str, Any]:
     """Process sampler stan_csv output file line by line."""
-    dict = {}
+    dict: Dict[str, Any] = {}
     lineno = 0
     with open(path, 'r') as fd:
         lineno = scan_config(fd, dict, lineno)
@@ -523,9 +531,9 @@ def scan_sampler_csv(path: str, is_fixed_param: bool = False) -> Dict:
     return dict
 
 
-def scan_optimize_csv(path: str) -> Dict:
+def scan_optimize_csv(path: str) -> Dict[str, Any]:
     """Process optimizer stan_csv output file line by line."""
-    dict = {}
+    dict: Dict[str, Any] = {}
     lineno = 0
     with open(path, 'r') as fd:
         lineno = scan_config(fd, dict, lineno)
@@ -536,11 +544,11 @@ def scan_optimize_csv(path: str) -> Dict:
     return dict
 
 
-def scan_generated_quantities_csv(path: str) -> Dict:
+def scan_generated_quantities_csv(path: str) -> Dict[str, Any]:
     """
     Process standalone generated quantities stan_csv output file line by line.
     """
-    dict = {}
+    dict: Dict[str, Any] = {}
     lineno = 0
     with open(path, 'r') as fd:
         lineno = scan_config(fd, dict, lineno)
@@ -548,9 +556,9 @@ def scan_generated_quantities_csv(path: str) -> Dict:
     return dict
 
 
-def scan_variational_csv(path: str) -> Dict:
+def scan_variational_csv(path: str) -> Dict[str, Any]:
     """Process advi stan_csv output file line by line."""
-    dict = {}
+    dict: Dict[str, Any] = {}
     lineno = 0
     with open(path, 'r') as fd:
         lineno = scan_config(fd, dict, lineno)
@@ -581,7 +589,7 @@ def scan_variational_csv(path: str) -> Dict:
     return dict
 
 
-def scan_config(fd: TextIO, config_dict: Dict, lineno: int) -> int:
+def scan_config(fd: TextIO, config_dict: Dict[str, Any], lineno: int) -> int:
     """
     Scan initial stan_csv file comments lines and
     save non-default configuration information to config_dict.
@@ -599,6 +607,7 @@ def scan_config(fd: TextIO, config_dict: Dict, lineno: int) -> int:
                 config_dict['data_file'] = key_val[1].strip()
             elif key_val[0].strip() != 'file':
                 raw_val = key_val[1].strip()
+                val: Union[int, float, str]
                 try:
                     val = int(raw_val)
                 except ValueError:
@@ -613,7 +622,9 @@ def scan_config(fd: TextIO, config_dict: Dict, lineno: int) -> int:
     return lineno
 
 
-def scan_warmup_iters(fd: TextIO, config_dict: Dict, lineno: int) -> int:
+def scan_warmup_iters(
+    fd: TextIO, config_dict: Dict[str, Any], lineno: int
+) -> int:
     """
     Check warmup iterations, if any.
     """
@@ -632,7 +643,9 @@ def scan_warmup_iters(fd: TextIO, config_dict: Dict, lineno: int) -> int:
     return lineno
 
 
-def scan_column_names(fd: TextIO, config_dict: Dict, lineno: int) -> int:
+def scan_column_names(
+    fd: TextIO, config_dict: MutableMapping[str, Any], lineno: int
+) -> int:
     """
     Process columns header, add to config_dict as 'column_names'
     """
@@ -685,9 +698,10 @@ def parse_stan_vars(
     """
     if names is None:
         raise ValueError('missing argument "names"')
-    dims_map = {}
-    cols_map = {}
+    dims_map: Dict[str, Tuple[int, ...]] = {}
+    cols_map: Dict[str, Tuple[int, ...]] = {}
     idxs = []
+    dims: Union[List[str], List[int]]
     for (idx, name) in enumerate(names):
         idxs.append(idx)
         var, *dims = name.split('[')
@@ -707,7 +721,7 @@ def parse_stan_vars(
     return (dims_map, cols_map)
 
 
-def scan_metric(fd: TextIO, config_dict: Dict, lineno: int) -> int:
+def scan_metric(fd: TextIO, config_dict: Dict[str, Any], lineno: int) -> int:
     """
     Scan step size, metric from  stan_csv file comment lines,
     set config_dict entries 'metric' and 'num_unconstrained_params'
@@ -768,7 +782,9 @@ def scan_metric(fd: TextIO, config_dict: Dict, lineno: int) -> int:
         return lineno
 
 
-def scan_sampling_iters(fd: TextIO, config_dict: Dict, lineno: int) -> int:
+def scan_sampling_iters(
+    fd: TextIO, config_dict: Dict[str, Any], lineno: int
+) -> int:
     """
     Parse sampling iteration, save number of iterations to config_dict.
     """
@@ -802,8 +818,8 @@ def read_metric(path: str) -> List[int]:
         with open(path, 'r') as fd:
             metric_dict = json.load(fd)
         if 'inv_metric' in metric_dict:
-            dims = np.asarray(metric_dict['inv_metric'])
-            return list(dims.shape)
+            dims_np = np.asarray(metric_dict['inv_metric'])
+            return list(dims_np.shape)
         else:
             raise ValueError(
                 'metric file {}, bad or missing'
@@ -824,7 +840,7 @@ def read_rdump_metric(path: str) -> List[int]:
     Find dimensions of variable named 'inv_metric' in Rdump data file.
     """
     metric_dict = rload(path)
-    if not (
+    if metric_dict is None or not (
         'inv_metric' in metric_dict
         and isinstance(metric_dict['inv_metric'], np.ndarray)
     ):
@@ -834,7 +850,11 @@ def read_rdump_metric(path: str) -> List[int]:
     return list(metric_dict['inv_metric'].shape)
 
 
-def do_command(cmd: str, cwd: str = None, logger: logging.Logger = None) -> str:
+def do_command(
+    cmd: List[str],
+    cwd: Optional[str] = None,
+    logger: Optional[logging.Logger] = None,
+) -> Optional[str]:
     """
     Spawn process, print stdout/stderr to console.
     Throws RuntimeError on non-zero returncode.
@@ -916,7 +936,10 @@ def windows_short_path(path: str) -> str:
     from ctypes import wintypes
 
     # pylint: disable=invalid-name
-    _GetShortPathNameW = ctypes.windll.kernel32.GetShortPathNameW
+    _GetShortPathNameW = (
+        ctypes.windll.kernel32.GetShortPathNameW  # type: ignore
+    )
+
     _GetShortPathNameW.argtypes = [
         wintypes.LPCWSTR,
         wintypes.LPWSTR,
@@ -960,8 +983,8 @@ def create_named_text_file(
 
 
 def install_cmdstan(
-    version: str = None,
-    dir: str = None,
+    version: Optional[str] = None,
+    dir: Optional[str] = None,
     overwrite: bool = False,
     verbose: bool = False,
 ) -> bool:
@@ -1008,7 +1031,7 @@ def install_cmdstan(
         stderr=subprocess.PIPE,
         env=os.environ,
     )
-    while proc.poll() is None:
+    while proc.poll() is None and proc.stdout:
         print(proc.stdout.readline().decode('utf-8').strip())
 
     _, stderr = proc.communicate()
@@ -1024,10 +1047,12 @@ class MaybeDictToFilePath:
     """Context manager for json files."""
 
     def __init__(
-        self, *objs: Union[str, dict, list], logger: logging.Logger = None
+        self,
+        *objs: Union[str, Dict[Any, Any], List[Any], int, float, None],
+        logger: Optional[logging.Logger] = None
     ):
         self._unlink = [False] * len(objs)
-        self._paths = [''] * len(objs)
+        self._paths: List[Any] = [''] * len(objs)
         self._logger = logger or get_logger()
         i = 0
         for obj in objs:
@@ -1072,16 +1097,16 @@ class MaybeDictToFilePath:
                 self._paths[i] = obj
             elif obj is None:
                 self._paths[i] = None
-            elif i == 1 and isinstance(obj, (Integral, Real)):
+            elif i == 1 and isinstance(obj, (int, float)):
                 self._paths[i] = obj
             else:
                 raise ValueError('data must be string or dict')
             i += 1
 
-    def __enter__(self):
+    def __enter__(self) -> List[str]:
         return self._paths
 
-    def __exit__(self, exc_type, exc_val, exc_tb):
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:  # type: ignore
         for can_unlink, path in zip(self._unlink, self._paths):
             if can_unlink and path:
                 try:
@@ -1094,7 +1119,6 @@ class TemporaryCopiedFile:
     """Context manager for tmpfiles, handles spaces in filepath."""
 
     def __init__(self, file_path: str):
-        self._path = None
         self._tmpdir = None
         if ' ' in os.path.abspath(file_path) and platform.system() == 'Windows':
             base_path, file_name = os.path.split(os.path.abspath(file_path))
@@ -1122,9 +1146,9 @@ class TemporaryCopiedFile:
         else:
             self._path = file_path
 
-    def __enter__(self):
+    def __enter__(self) -> Tuple[str, bool]:
         return self._path, self._tmpdir is not None
 
-    def __exit__(self, exc_type, exc_val, exc_tb):
+    def __exit__(self, exc_type, exc_val, exc_tb) -> None:  # type: ignore
         if self._tmpdir:
             shutil.rmtree(self._tmpdir, ignore_errors=True)
diff --git a/cmdstanpy_tutorial.py b/cmdstanpy_tutorial.py
index 01feeb6..18547d6 100644
--- a/cmdstanpy_tutorial.py
+++ b/cmdstanpy_tutorial.py
@@ -3,11 +3,12 @@
 
 # ### Import CmdStanPy classes and methods
 
+import os
+
 import matplotlib
 import pandas as pd
-import os
 
-from cmdstanpy import cmdstan_path, CmdStanModel
+from cmdstanpy import CmdStanModel, cmdstan_path
 
 # ### Instantiate & compile the model
 
diff --git a/docsrc/conf.py b/docsrc/conf.py
index dfe0689..6f993ea 100644
--- a/docsrc/conf.py
+++ b/docsrc/conf.py
@@ -344,4 +344,3 @@ texinfo_documents = [
 #     'reference_url': {
 #         'skltemplate': None}
 # }
-
diff --git a/pyproject.toml b/pyproject.toml
index 484b31d..e86cd2f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -10,3 +10,14 @@ use_parentheses = true
 ensure_newline_before_comments = true
 line_length = 80
 
+[tool.mypy]
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+no_implicit_optional = true
+# disallow_any_generics = true # disabled due to issues with numpy < 1.20
+warn_return_any = true
+warn_unused_ignores = true
+
+[[tool.mypy.overrides]]
+module = ['tqdm', 'pandas', 'ujson']
+ignore_missing_imports = true


Test patch:
diff --git a/test/test_compiler_opts.py b/test/test_compiler_opts.py
index f937db6..8b46912 100644
--- a/test/test_compiler_opts.py
+++ b/test/test_compiler_opts.py
@@ -14,9 +14,7 @@ class CompilerOptsTest(unittest.TestCase):
         opts = CompilerOptions()
         opts.validate()
         self.assertEqual(opts.compose(), [])
-        self.assertEqual(
-            opts.__repr__(), 'stanc_options=None, cpp_options=None'
-        )
+        self.assertEqual(opts.__repr__(), 'stanc_options={}, cpp_options={}')
 
         stanc_opts = {}
         opts = CompilerOptions(stanc_options=stanc_opts)
diff --git a/test/test_optimize.py b/test/test_optimize.py
index 73535e9..7ef58f2 100644
--- a/test/test_optimize.py
+++ b/test/test_optimize.py
@@ -182,7 +182,7 @@ class OptimizeTest(unittest.TestCase):
                 seed=1239812093,
                 inits=jinit,
                 algorithm='LBFGS',
-                tol_obj=-1,
+                tol_obj=-1.0,
             )
 
         with self.assertRaisesRegex(ValueError, 'must be greater than'):
@@ -191,7 +191,7 @@ class OptimizeTest(unittest.TestCase):
                 seed=1239812093,
                 inits=jinit,
                 algorithm='LBFGS',
-                tol_rel_obj=-1,
+                tol_rel_obj=-1.0,
             )
 
         with self.assertRaisesRegex(ValueError, 'must be greater than'):
@@ -200,7 +200,7 @@ class OptimizeTest(unittest.TestCase):
                 seed=1239812093,
                 inits=jinit,
                 algorithm='LBFGS',
-                tol_grad=-1,
+                tol_grad=-1.0,
             )
 
         with self.assertRaisesRegex(ValueError, 'must be greater than'):
@@ -209,7 +209,7 @@ class OptimizeTest(unittest.TestCase):
                 seed=1239812093,
                 inits=jinit,
                 algorithm='LBFGS',
-                tol_rel_grad=-1,
+                tol_rel_grad=-1.0,
             )
 
         with self.assertRaisesRegex(ValueError, 'must be greater than'):
@@ -218,7 +218,7 @@ class OptimizeTest(unittest.TestCase):
                 seed=1239812093,
                 inits=jinit,
                 algorithm='LBFGS',
-                tol_param=-1,
+                tol_param=-1.0,
             )
 
         with self.assertRaisesRegex(ValueError, 'must be greater than'):
diff --git a/test/test_utils.py b/test/test_utils.py
index cedea63..3336651 100644
--- a/test/test_utils.py
+++ b/test/test_utils.py
@@ -1,5 +1,6 @@
 """utils test"""
 
+import collections.abc
 import json
 import os
 import platform
@@ -9,7 +10,6 @@ import stat
 import string
 import tempfile
 import unittest
-import collections.abc
 
 import numpy as np
 import pandas as pd
@@ -25,8 +25,8 @@ from cmdstanpy.utils import (
     do_command,
     get_latest_cmdstan,
     jsondump,
-    parse_rdump_value,
     parse_method_vars,
+    parse_rdump_value,
     parse_stan_vars,
     rdump,
     read_metric,