envcaster 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
envcaster/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """envcaster — typed, dependency-free environment variable loading.
2
+
3
+ Read environment variables as the type you actually want — ``int``, ``float``,
4
+ ``bool``, ``list``, ``json``, ``Path`` — with defaults, required-checks, and
5
+ error messages that tell you exactly which variable was wrong and why. Plus a
6
+ tiny ``.env`` loader. Zero dependencies, pure standard library.
7
+
8
+ from envcaster import env
9
+
10
+ PORT = env.int("PORT", default=8000)
11
+ DEBUG = env.bool("DEBUG", default=False)
12
+ HOSTS = env.list("ALLOWED_HOSTS", sep=",")
13
+ SECRET = env.str("SECRET_KEY", required=True)
14
+ """
15
+
16
+ from envcaster.core import CastError, Env, EnvError, MissingEnvError, env
17
+ from envcaster.dotenv import load_dotenv, read_dotenv
18
+
19
+ __version__ = "0.1.0"
20
+
21
+ __all__ = [
22
+ "env",
23
+ "Env",
24
+ "EnvError",
25
+ "MissingEnvError",
26
+ "CastError",
27
+ "load_dotenv",
28
+ "read_dotenv",
29
+ ]
envcaster/core.py ADDED
@@ -0,0 +1,210 @@
1
+ """Typed reads from the environment with defaults, requirements, and clear errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import json as _json
7
+ import os
8
+ from pathlib import Path
9
+ from typing import Any, Callable, List, Mapping, Optional, TypeVar
10
+
11
+ __all__ = [
12
+ "Env",
13
+ "EnvError",
14
+ "MissingEnvError",
15
+ "CastError",
16
+ "env",
17
+ ]
18
+
19
+ T = TypeVar("T")
20
+
21
+
22
+ class EnvError(Exception):
23
+ """Base class for all envcaster errors."""
24
+
25
+
26
+ class MissingEnvError(EnvError, KeyError):
27
+ """A required environment variable was not set."""
28
+
29
+ def __init__(self, name: str) -> None:
30
+ self.name = name
31
+ super().__init__(f"Required environment variable {name!r} is not set.")
32
+
33
+
34
+ class CastError(EnvError, ValueError):
35
+ """An environment variable could not be cast to the requested type."""
36
+
37
+ def __init__(self, name: str, value: str, type_name: str, hint: str = "") -> None:
38
+ self.name = name
39
+ self.value = value
40
+ self.type_name = type_name
41
+ msg = f"Environment variable {name!r}={value!r} is not a valid {type_name}."
42
+ if hint:
43
+ msg += f" {hint}"
44
+ super().__init__(msg)
45
+
46
+
47
+ # Sentinels — distinct from any user value (including None).
48
+ class _Unset:
49
+ def __repr__(self) -> str: # pragma: no cover - cosmetic
50
+ return "<unset>"
51
+
52
+
53
+ _UNSET: Any = _Unset()
54
+ _USE_DEFAULT: Any = object()
55
+
56
+ _TRUE = {"1", "true", "t", "yes", "y", "on"}
57
+ _FALSE = {"0", "false", "f", "no", "n", "off"}
58
+
59
+
60
+ class Env:
61
+ """A typed reader over a mapping of environment variables.
62
+
63
+ By default it reads the live process environment (``os.environ``). Pass a
64
+ ``source`` mapping (e.g. a plain ``dict``) to read from somewhere else —
65
+ handy in tests. A ``prefix`` is prepended to every name you look up, so
66
+ ``Env(prefix="APP_").int("PORT")`` reads ``APP_PORT``.
67
+
68
+ A variable is **required unless you pass a ``default``**. Missing required
69
+ variables raise :class:`MissingEnvError`; bad values raise :class:`CastError`.
70
+ Both subclass :class:`EnvError` (and the matching builtin), so you can catch
71
+ broadly or narrowly.
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ source: Optional[Mapping[str, str]] = None,
77
+ *,
78
+ prefix: str = "",
79
+ ) -> None:
80
+ self._source = source
81
+ self._prefix = prefix
82
+
83
+ # -- internals ---------------------------------------------------------
84
+
85
+ def _mapping(self) -> Mapping[str, str]:
86
+ # Read os.environ lazily so changes after construction are seen.
87
+ return os.environ if self._source is None else self._source
88
+
89
+ def _raw(self, name: str, default: Any, required: bool) -> str:
90
+ key = self._prefix + name
91
+ value = self._mapping().get(key)
92
+ if value is None:
93
+ if required or default is _UNSET:
94
+ raise MissingEnvError(key)
95
+ return _USE_DEFAULT
96
+ return value
97
+
98
+ # -- typed getters -----------------------------------------------------
99
+
100
+ def str(self, name: str, default: Any = _UNSET, *, required: bool = False) -> str:
101
+ """Return the variable as a string (the raw value, unchanged)."""
102
+ raw = self._raw(name, default, required)
103
+ return default if raw is _USE_DEFAULT else raw
104
+
105
+ def int(self, name: str, default: Any = _UNSET, *, required: bool = False) -> int:
106
+ """Return the variable parsed as an ``int`` (base-10, whitespace ignored)."""
107
+ raw = self._raw(name, default, required)
108
+ if raw is _USE_DEFAULT:
109
+ return default
110
+ try:
111
+ return int(raw.strip())
112
+ except ValueError:
113
+ raise CastError(self._prefix + name, raw, "integer") from None
114
+
115
+ def float(self, name: str, default: Any = _UNSET, *, required: bool = False) -> float:
116
+ """Return the variable parsed as a ``float``."""
117
+ raw = self._raw(name, default, required)
118
+ if raw is _USE_DEFAULT:
119
+ return default
120
+ try:
121
+ return float(raw.strip())
122
+ except ValueError:
123
+ raise CastError(self._prefix + name, raw, "float") from None
124
+
125
+ def bool(self, name: str, default: Any = _UNSET, *, required: bool = False) -> bool:
126
+ """Return the variable as a ``bool``.
127
+
128
+ Truthy: ``1 true t yes y on``. Falsy: ``0 false f no n off``
129
+ (case-insensitive). Anything else raises :class:`CastError`.
130
+ """
131
+ raw = self._raw(name, default, required)
132
+ if raw is _USE_DEFAULT:
133
+ return default
134
+ token = raw.strip().lower()
135
+ if token in _TRUE:
136
+ return True
137
+ if token in _FALSE:
138
+ return False
139
+ raise CastError(
140
+ self._prefix + name,
141
+ raw,
142
+ "boolean",
143
+ hint=f"Use one of: {', '.join(sorted(_TRUE | _FALSE))}.",
144
+ )
145
+
146
+ def list(
147
+ self,
148
+ name: str,
149
+ default: Any = _UNSET,
150
+ *,
151
+ sep: str = ",",
152
+ cast: Callable[[str], T] = builtins.str, # type: ignore[assignment]
153
+ required: bool = False,
154
+ ) -> List[T]:
155
+ """Split the variable on ``sep`` into a list.
156
+
157
+ Items are stripped of surrounding whitespace and empty items dropped.
158
+ Pass ``cast`` to convert each item (e.g. ``cast=int``).
159
+ """
160
+ raw = self._raw(name, default, required)
161
+ if raw is _USE_DEFAULT:
162
+ return default
163
+ items = [piece.strip() for piece in raw.split(sep)]
164
+ items = [piece for piece in items if piece]
165
+ try:
166
+ return [cast(piece) for piece in items]
167
+ except (ValueError, TypeError):
168
+ raise CastError(self._prefix + name, raw, "list", hint="An item failed to cast.") from None
169
+
170
+ def json(self, name: str, default: Any = _UNSET, *, required: bool = False) -> Any:
171
+ """Parse the variable as JSON and return the resulting object."""
172
+ raw = self._raw(name, default, required)
173
+ if raw is _USE_DEFAULT:
174
+ return default
175
+ try:
176
+ return _json.loads(raw)
177
+ except _json.JSONDecodeError:
178
+ raise CastError(self._prefix + name, raw, "JSON") from None
179
+
180
+ def path(self, name: str, default: Any = _UNSET, *, required: bool = False) -> Path:
181
+ """Return the variable as a :class:`pathlib.Path` (not resolved/validated)."""
182
+ raw = self._raw(name, default, required)
183
+ if raw is _USE_DEFAULT:
184
+ return default
185
+ return Path(raw)
186
+
187
+ def cast(
188
+ self,
189
+ name: str,
190
+ func: Callable[[str], T],
191
+ default: Any = _UNSET,
192
+ *,
193
+ required: bool = False,
194
+ ) -> T:
195
+ """Apply an arbitrary ``func`` to the raw string value.
196
+
197
+ Any exception from ``func`` is wrapped in :class:`CastError`.
198
+ """
199
+ raw = self._raw(name, default, required)
200
+ if raw is _USE_DEFAULT:
201
+ return default
202
+ try:
203
+ return func(raw)
204
+ except Exception as exc: # noqa: BLE001 - re-raised as CastError
205
+ type_name = getattr(func, "__name__", "value")
206
+ raise CastError(self._prefix + name, raw, type_name, hint=str(exc)) from None
207
+
208
+
209
+ # A ready-to-use instance bound to the live process environment.
210
+ env = Env()
envcaster/dotenv.py ADDED
@@ -0,0 +1,67 @@
1
+ """A tiny, dependency-free ``.env`` reader.
2
+
3
+ Just enough to load a local ``.env`` in development. For complex needs
4
+ (variable interpolation, multiline values) reach for python-dotenv.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from pathlib import Path
11
+ from typing import Dict, Union
12
+
13
+ __all__ = ["read_dotenv", "load_dotenv"]
14
+
15
+ _PathLike = Union[str, "os.PathLike[str]"]
16
+
17
+
18
+ def _unquote(value: str) -> str:
19
+ value = value.strip()
20
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
21
+ return value[1:-1]
22
+ return value
23
+
24
+
25
+ def read_dotenv(path: _PathLike = ".env") -> Dict[str, str]:
26
+ """Parse a ``.env`` file into a dict. Returns ``{}`` if the file is absent.
27
+
28
+ Supports ``KEY=value``, ``export KEY=value``, ``#`` comments, blank lines,
29
+ and single/double-quoted values. Does **not** touch ``os.environ``.
30
+ """
31
+ file = Path(path)
32
+ if not file.is_file():
33
+ return {}
34
+
35
+ result: Dict[str, str] = {}
36
+ for raw_line in file.read_text(encoding="utf-8").splitlines():
37
+ line = raw_line.strip()
38
+ if not line or line.startswith("#"):
39
+ continue
40
+ if line.startswith("export "):
41
+ line = line[len("export ") :].lstrip()
42
+ if "=" not in line:
43
+ continue
44
+ key, _, value = line.partition("=")
45
+ key = key.strip()
46
+ if not key:
47
+ continue
48
+ # Strip an inline comment only when the value is not quoted.
49
+ stripped = value.strip()
50
+ if stripped[:1] not in ("'", '"') and " #" in value:
51
+ value = value.split(" #", 1)[0]
52
+ result[key] = _unquote(value)
53
+ return result
54
+
55
+
56
+ def load_dotenv(path: _PathLike = ".env", *, override: bool = False) -> Dict[str, str]:
57
+ """Read a ``.env`` file and inject its values into ``os.environ``.
58
+
59
+ By default existing environment variables win (``override=False``), so real
60
+ environment configuration is never clobbered by the file. Returns the dict
61
+ that was parsed from the file.
62
+ """
63
+ values = read_dotenv(path)
64
+ for key, value in values.items():
65
+ if override or key not in os.environ:
66
+ os.environ[key] = value
67
+ return values
envcaster/py.typed ADDED
File without changes
@@ -0,0 +1,207 @@
1
+ Metadata-Version: 2.4
2
+ Name: envcaster
3
+ Version: 0.1.0
4
+ Summary: Typed, dependency-free environment variable loading: read env vars as int/bool/list/json/Path with defaults, required-checks, and clear errors.
5
+ Author: YoungAlpaccino
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/YoungAlpaccino/envcast
8
+ Project-URL: Repository, https://github.com/YoungAlpaccino/envcast
9
+ Project-URL: Issues, https://github.com/YoungAlpaccino/envcast/issues
10
+ Keywords: env,environment,config,dotenv,settings,12factor,typed,configuration
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Classifier: Typing :: Typed
22
+ Requires-Python: >=3.9
23
+ Description-Content-Type: text/markdown
24
+ License-File: LICENSE
25
+ Provides-Extra: dev
26
+ Requires-Dist: pytest>=7; extra == "dev"
27
+ Requires-Dist: ruff; extra == "dev"
28
+ Requires-Dist: build; extra == "dev"
29
+ Requires-Dist: twine; extra == "dev"
30
+ Dynamic: license-file
31
+
32
+ # envcaster ⚙️
33
+
34
+ > Read environment variables as the **type you actually want** — `int`, `bool`, `list`, `json`, `Path` — with defaults, required-checks, and errors that name the offending variable. Zero dependencies, pure standard library.
35
+
36
+ [![CI](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml/badge.svg)](https://github.com/YoungAlpaccino/envcast/actions/workflows/ci.yml)
37
+ [![PyPI](https://img.shields.io/pypi/v/envcaster.svg)](https://pypi.org/project/envcaster/)
38
+ [![Python](https://img.shields.io/pypi/pyversions/envcaster.svg)](https://pypi.org/project/envcaster/)
39
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
40
+
41
+ `os.environ` only ever gives you strings. So every project grows the same little
42
+ pile of `int(os.environ.get("PORT", "8000"))` and hand-rolled truthy checks that
43
+ quietly treat `"False"` as `True`. **envcaster** is that pile, done once and done right.
44
+
45
+ - 🪶 **Zero required dependencies** — pure standard library.
46
+ - 🎯 **Typed getters** — `str · int · float · bool · list · json · path` (+ custom `cast`).
47
+ - 🧯 **Loud, precise errors** — missing or malformed values tell you *which* variable and *why*.
48
+ - 🧪 **Fully tested** across Python 3.9–3.12.
49
+ - 🧩 **Drop-in `.env` loader** that never clobbers real environment config.
50
+
51
+ ---
52
+
53
+ ## Install
54
+
55
+ ```bash
56
+ pip install envcaster
57
+ ```
58
+
59
+ ---
60
+
61
+ ## Quick start
62
+
63
+ ```python
64
+ from envcaster import env
65
+
66
+ PORT = env.int("PORT", default=8000)
67
+ DEBUG = env.bool("DEBUG", default=False)
68
+ HOSTS = env.list("ALLOWED_HOSTS", sep=",") # ["a", "b"] from "a,b"
69
+ TIMEOUT = env.float("TIMEOUT", default=1.5)
70
+ SECRET = env.str("SECRET_KEY", required=True) # raises if not set
71
+ DATA = env.path("DATA_DIR", default="/var/data") # -> pathlib.Path
72
+ FLAGS = env.json("FEATURE_FLAGS", default={}) # parsed JSON
73
+ ```
74
+
75
+ > **A variable is required unless you give it a `default`.** Missing required
76
+ > variables raise `MissingEnvError`; bad values raise `CastError`. Both subclass
77
+ > `EnvError` — catch broadly or narrowly.
78
+
79
+ ---
80
+
81
+ ## Usage
82
+
83
+ ### Booleans that actually behave
84
+
85
+ ```python
86
+ env.bool("DEBUG") # 1 true t yes y on -> True (case-insensitive)
87
+ # 0 false f no n off -> False
88
+ # anything else -> CastError
89
+ ```
90
+
91
+ No more `bool("False") == True` bugs.
92
+
93
+ ### Lists (and lists of other types)
94
+
95
+ ```python
96
+ env.list("ALLOWED_HOSTS") # "a, b ,, c" -> ["a", "b", "c"] (trims, drops empties)
97
+ env.list("PORTS", sep=":", cast=int) # "80:443" -> [80, 443]
98
+ env.list("TAGS", default=[]) # missing -> []
99
+ ```
100
+
101
+ ### JSON and paths
102
+
103
+ ```python
104
+ env.json("LIMITS") # '{"rpm": 60}' -> {"rpm": 60}
105
+ env.path("LOG_DIR") # "/var/log" -> PosixPath("/var/log")
106
+ ```
107
+
108
+ ### Anything else — bring your own cast
109
+
110
+ ```python
111
+ from decimal import Decimal
112
+
113
+ env.cast("PRICE", Decimal) # "9.99" -> Decimal("9.99")
114
+ env.cast("COLOR", lambda v: int(v, 16)) # "ff0000" -> 16711680
115
+ # exceptions from your function are wrapped in CastError, naming the variable
116
+ ```
117
+
118
+ ### Scoped readers with a prefix
119
+
120
+ ```python
121
+ from envcaster import Env
122
+
123
+ app = Env(prefix="APP_")
124
+ app.int("PORT") # reads APP_PORT
125
+ app.bool("DEBUG") # reads APP_DEBUG
126
+ ```
127
+
128
+ ### Read from somewhere other than `os.environ`
129
+
130
+ ```python
131
+ cfg = Env(source={"PORT": "9000"}) # great for tests — no global state
132
+ cfg.int("PORT") # 9000
133
+ ```
134
+
135
+ ### Load a `.env` file (no dependency)
136
+
137
+ ```python
138
+ from envcaster import load_dotenv, env
139
+
140
+ load_dotenv() # reads ./.env into os.environ (won't override real env vars)
141
+ load_dotenv(".env.local", override=True)
142
+
143
+ PORT = env.int("PORT")
144
+
145
+ # Or parse without touching the environment:
146
+ from envcaster import read_dotenv
147
+ values = read_dotenv(".env") # -> {"PORT": "8000", ...}
148
+ ```
149
+
150
+ Handles `KEY=value`, `export KEY=value`, `# comments`, and quoted values. For
151
+ interpolation or multiline values, use [python-dotenv](https://github.com/theskumar/python-dotenv).
152
+
153
+ ---
154
+
155
+ ## API reference
156
+
157
+ | Call | Returns | Notes |
158
+ |---|---|---|
159
+ | `env.str(name, default=…, required=False)` | `str` | The raw value, unchanged |
160
+ | `env.int(name, …)` | `int` | Base-10, whitespace stripped |
161
+ | `env.float(name, …)` | `float` | |
162
+ | `env.bool(name, …)` | `bool` | `1/true/t/yes/y/on` ↔ `0/false/f/no/n/off` |
163
+ | `env.list(name, …, sep=",", cast=str)` | `list` | Trims items, drops empties, per-item `cast` |
164
+ | `env.json(name, …)` | `Any` | `json.loads` of the value |
165
+ | `env.path(name, …)` | `pathlib.Path` | Not resolved/validated |
166
+ | `env.cast(name, func, …)` | `Any` | Apply any callable; errors wrapped in `CastError` |
167
+ | `Env(source=None, prefix="")` | `Env` | Custom mapping and/or name prefix |
168
+ | `read_dotenv(path=".env")` | `dict` | Parse a `.env` file; `{}` if absent |
169
+ | `load_dotenv(path=".env", override=False)` | `dict` | Inject into `os.environ` |
170
+
171
+ **Errors:** `EnvError` (base) · `MissingEnvError` (also `KeyError`) · `CastError` (also `ValueError`).
172
+
173
+ ---
174
+
175
+ ## Why not just `os.environ`?
176
+
177
+ ```python
178
+ # Before
179
+ import os
180
+ PORT = int(os.environ.get("PORT", "8000"))
181
+ DEBUG = os.environ.get("DEBUG", "false").lower() in ("1", "true", "yes")
182
+ HOSTS = [h.strip() for h in os.environ.get("ALLOWED_HOSTS", "").split(",") if h.strip()]
183
+
184
+ # After
185
+ from envcaster import env
186
+ PORT = env.int("PORT", default=8000)
187
+ DEBUG = env.bool("DEBUG", default=False)
188
+ HOSTS = env.list("ALLOWED_HOSTS", default=[])
189
+ ```
190
+
191
+ ---
192
+
193
+ ## Development
194
+
195
+ ```bash
196
+ git clone https://github.com/YoungAlpaccino/envcast
197
+ cd envcast
198
+ pip install -e ".[dev]"
199
+ pytest # run tests
200
+ ruff check . # lint
201
+ ```
202
+
203
+ ---
204
+
205
+ ## License
206
+
207
+ MIT — see [LICENSE](./LICENSE). Use it anywhere, including commercially.
@@ -0,0 +1,9 @@
1
+ envcaster/__init__.py,sha256=L2yif83QketIcGkbhtAr7s2DC4kGLvttCDWCeH_oqVo,875
2
+ envcaster/core.py,sha256=gyL46ehOyABaa86suU_yLeIfflosOSBxNGwlYe13D9s,7168
3
+ envcaster/dotenv.py,sha256=IvFRxXRkx7e9-DIg87n9KF1nlIlMBuloX24-U6o9hIA,2173
4
+ envcaster/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ envcaster-0.1.0.dist-info/licenses/LICENSE,sha256=I6OFRouQilM7XAjOq3RJp8glXpwdnQZZBsrJ5L6Ctso,1071
6
+ envcaster-0.1.0.dist-info/METADATA,sha256=bDAUzE8gDkb2nYvikLQ121U3Ixoam-_-2CeNm6PjZSI,7282
7
+ envcaster-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
8
+ envcaster-0.1.0.dist-info/top_level.txt,sha256=0UJS2e59c1eyhYh2jJzyl2fmmR22GHvpJrArXPD64nI,10
9
+ envcaster-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 YoungAlpaccino
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ envcaster