diff --git a/biome.json b/biome.json index 7c43b424..d3ac76f6 100644 --- a/biome.json +++ b/biome.json @@ -13,7 +13,8 @@ "*.d.ts", "playground/**/*.js", "playground/serve.ts", - "benchmarks/**" + "benchmarks/**", + ".autoloop/**" ] }, "formatter": { @@ -68,6 +69,7 @@ } }, "javascript": { + "globals": ["Bun"], "formatter": { "quoteStyle": "double", "trailingCommas": "all", diff --git a/playground/grouper.html b/playground/grouper.html new file mode 100644 index 00000000..da23e240 --- /dev/null +++ b/playground/grouper.html @@ -0,0 +1,254 @@ + + + + + + tsb — Grouper + + + + +
+
+
Initializing playground…
+
+ + ← Back to roadmap +

pd.Grouper

+

Grouper is a specification object that encapsulates groupby parameters — mirrors pandas.Grouper.

+ +
+

1 — Key vs Level grouping

+

Create a Grouper for column-key grouping or index-level grouping.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

2 — Options & toString

+

Full set of Grouper options: freq, sort, dropna, closed, label.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

3 — Usage with groupby

+

Use g.key! to pass the key directly to groupby(). Full Grouper integration (freq/level) is a future iteration.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ + + + + + diff --git a/playground/hash_array_itertuples.html b/playground/hash_array_itertuples.html new file mode 100644 index 00000000..bbd8e7e3 --- /dev/null +++ b/playground/hash_array_itertuples.html @@ -0,0 +1,328 @@ + + + + + + tsb — hashArray / itertuples / Series.items Playground + + + + + +
+
+
Initializing playground…
+
+ + ← Back to roadmap +

🔢 hashArray / itertuples / Series.items — Interactive Playground

+

+ Utility hashing and row-iteration APIs — mirrors + pandas.util.hash_array, DataFrame.itertuples(), + and Series.items().
+ Edit any code block below and press ▶ Run + (or Ctrl+Enter) to execute it live in your browser. +

+ + +
+

1 · hashArray

+

+ Hash an array of scalar values element-wise using FNV-1a 64-bit. + Identical inputs always produce the same hash value. +

+
+
+ TypeScript +
+ + +
+
+
import { hashArray } from "tsb";
+
+const arr = [1, "hello", null, true, 42];
+const hashes = hashArray(arr);
+console.log("Hashes:", hashes);
+
+// Duplicate inputs get the same hash
+const h2 = hashArray(["a", "b", "a"]);
+console.log("h2[0] === h2[2]:", h2[0] === h2[2]);
+
Click ▶ Run to execute
+
Ctrl+Enter to run
+
+
+ + +
+

2 · Series.items() / iteritems()

+

+ Iterate over (label, value) pairs from a Series. + iteritems() is an alias for compatibility. +

+
+
+ TypeScript +
+ + +
+
+
import { Series } from "tsb";
+
+const s = new Series({ data: [10, 20, 30], index: ["a", "b", "c"] });
+for (const [label, value] of s.items()) {
+  console.log(label, "→", value);
+}
+
+// iteritems() is an alias
+console.log("\nvia iteritems:");
+console.log([...s.iteritems()]);
+
Click ▶ Run to execute
+
Ctrl+Enter to run
+
+
+ + +
+

3 · DataFrame.itertuples()

+

+ Iterate over rows as plain objects with an Index field. + Pass false to omit the index from each row object. +

+
+
+ TypeScript +
+ + +
+
+
import { DataFrame } from "tsb";
+
+const df = DataFrame.fromColumns({
+  name:  ["Alice", "Bob", "Carol"],
+  score: [95, 87, 92],
+});
+for (const row of df.itertuples()) {
+  console.log(row);
+}
+
+console.log("\nWithout index:");
+console.log([...df.itertuples(false)]);
+
Click ▶ Run to execute
+
Ctrl+Enter to run
+
+
+ + +
+

🧪 Scratch Pad

+

Write your own code using hashArray, Series.items(), + or DataFrame.itertuples(). All exports from tsb are available.

+
+
+ TypeScript — Scratch Pad +
+ + +
+
+
import { hashArray, Series, DataFrame } from "tsb";
+
+// Combine: hash the values from a Series
+const s = new Series({ data: ["foo", "bar", "foo"], index: [0, 1, 2] });
+const vals = [...s.items()].map(([, v]) => v);
+const hashes = hashArray(vals);
+console.log("foo===foo:", hashes[0] === hashes[2]);
+console.log("foo===bar:", hashes[0] === hashes[1]);
+
Click ▶ Run to execute
+
Ctrl+Enter to run
+
+
+ + + + + + + diff --git a/playground/index.html b/playground/index.html index 27ccedd2..8df2e0e9 100644 --- a/playground/index.html +++ b/playground/index.html @@ -284,6 +284,21 @@

✅ Complete +
+

🪟 Window Indexers

+

Custom window indexers for rolling computations: BaseIndexer (abstract base), FixedForwardWindowIndexer (forward-looking N-row window), VariableOffsetWindowIndexer (per-row variable depth), and applyIndexer() helper. Mirrors pandas.api.indexers.

+
✅ Complete
+
+
+

🗺️ Series.map()

+

Map Series values using a function, Record/dict, another Series (index-label lookup), or ES6 Map. Missing keys produce null. Optional naAction: "ignore" passes NA values through unchanged. Mirrors pandas.Series.map().

+
✅ Complete
+
+
+

⚙️ pd.options system

+

getOption · setOption · resetOption · describeOption · optionContext · options proxy. Full validator support and 20+ built-in options across display.*, mode.*, compute.* namespaces. Mirrors pandas.get_option / pandas.set_option.

+
✅ Complete
+

🎭 where / mask

Element-wise conditional selection: seriesWhere / seriesMask and dataFrameWhere / dataFrameMask. Accepts boolean arrays, label-aligned boolean Series/DataFrame, or callables. Mirrors pandas.Series.where, pandas.DataFrame.where, and their .mask() inverses.

diff --git a/playground/options.html b/playground/options.html new file mode 100644 index 00000000..c3e74cf0 --- /dev/null +++ b/playground/options.html @@ -0,0 +1,281 @@ + + + + + + tsb — pd.options system + + + + +
+
+
Initializing playground…
+
+ + ← Back to roadmap +

pd.options system

+

The options system mirrors pandas.get_option / + pandas.set_option. It manages display, mode, and compute settings with full + validation support.

+ +
+

1 — getOption / setOption / resetOption

+

Read, write, and restore option values by dot-separated key.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

2 — describeOption

+

Pretty-print documentation for one or all options.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

3 — optionContext (scoped override)

+

Temporarily override options and restore them with enter() / exit().

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

4 — options proxy

+

Access options via a deeply-nested proxy object for ergonomic reads and writes.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

5 — registerOption (custom option)

+

Extend the registry with application-specific options, including custom validators.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ + + + + + diff --git a/playground/series-map.html b/playground/series-map.html new file mode 100644 index 00000000..171809b1 --- /dev/null +++ b/playground/series-map.html @@ -0,0 +1,281 @@ + + + + + + tsb — Series.map() + + + + +
+
+
Initializing playground…
+
+ + ← Back to roadmap +

Series.map()

+

Map values using a function, Record, Series, or ES6 Map — mirrors pandas.Series.map.

+ +
+

1 — Function mapper

+

Apply a function (value, index, pos) => U to every element.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

2 — Record / dict mapper

+

Look up each value (stringified) in a plain JS object. Missing keys produce null.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

3 — Series mapper

+

Look up each value by label in another Series. Missing labels produce null.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

4 — ES6 Map mapper

+

Use a native Map<Scalar, U> for non-string keys (numbers, booleans, null, etc.).

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

5 — naAction: "ignore"

+

Pass { naAction: "ignore" } to preserve existing null/NaN values without looking them up.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ + + + + + diff --git a/playground/window_indexers.html b/playground/window_indexers.html new file mode 100644 index 00000000..61aa43f5 --- /dev/null +++ b/playground/window_indexers.html @@ -0,0 +1,254 @@ + + + + + + tsb — Window Indexers + + + + +
+
+
Initializing playground…
+
+ + ← Back to roadmap +

🪟 Window Indexers

+

Custom window indexers let you define arbitrary window shapes for rolling computations — mirrors pandas.api.indexers.

+ +
+

1 — FixedForwardWindowIndexer

+

The default rolling looks backward. FixedForwardWindowIndexer looks forward — each row's window covers the next N rows.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

2 — VariableOffsetWindowIndexer

+

Define a different look-back (or look-forward) depth for each row. Useful for event-driven windows or irregular-frequency time series.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ +
+

3 — Custom BaseIndexer subclass

+

Subclass BaseIndexer to implement any window shape you need.

+
+
+ TypeScript +
+ + +
+
+ +
Click ▶ Run to execute
+
Ctrl+Enter to run · Tab to indent
+
+
+ + + + + + diff --git a/src/core/api_types.ts b/src/core/api_types.ts index 12f53671..2820e218 100644 --- a/src/core/api_types.ts +++ b/src/core/api_types.ts @@ -73,8 +73,9 @@ export function isScalar(val: unknown): boolean { } /** - * Return `true` if `val` is "list-like" — i.e. iterable (but not a string) - * or has a non-negative integer `length` property. + * Return `true` if `val` is "list-like" — i.e. iterable or has a + * non-negative integer `length` property. Strings are included, matching + * the behaviour of `pandas.api.types.is_list_like`. * * Mirrors `pandas.api.types.is_list_like`. * @@ -82,7 +83,7 @@ export function isScalar(val: unknown): boolean { * ```ts * isListLike([1, 2, 3]); // true * isListLike(new Set([1])); // true - * isListLike("abc"); // false (strings excluded) + * isListLike("abc"); // true * isListLike(42); // false * isListLike({ a: 1 }); // false * ``` @@ -92,7 +93,7 @@ export function isListLike(val: unknown): boolean { return false; } if (typeof val === "string") { - return false; + return true; } // Has Symbol.iterator and is not a plain number/boolean/bigint/symbol if ( diff --git a/src/core/date_range.ts b/src/core/date_range.ts index 4560db94..f893ae81 100644 --- a/src/core/date_range.ts +++ b/src/core/date_range.ts @@ -530,18 +530,16 @@ export function bdate_range(options: DateRangeOptions): DatetimeIndex { const MAX_ITER = 1_000_000; -function buildRange(options: DateRangeOptions, defaultFreq: DateRangeFreq): DatetimeIndex { - const { start, end, periods, normalize = false, name = null } = options; - const freq = options.freq ?? defaultFreq; - const offset = freqToOffset(freq); - +function resolveBoundDates( + start: string | Date | undefined, + end: string | Date | undefined, + normalize: boolean, +): { startDate: Date | null; endDate: Date | null } { if (start === undefined && end === undefined) { throw new Error("date_range: at least one of 'start' or 'end' must be provided"); } - let startDate = start !== undefined ? toDate(start) : null; let endDate = end !== undefined ? toDate(end) : null; - if (normalize) { if (startDate !== null) { startDate = normDate(startDate); @@ -550,19 +548,33 @@ function buildRange(options: DateRangeOptions, defaultFreq: DateRangeFreq): Date endDate = normDate(endDate); } } + return { startDate, endDate }; +} - let dates: Date[]; - +function selectDates( + startDate: Date | null, + endDate: Date | null, + periods: number | undefined, + offset: DateOffset, +): Date[] { if (startDate !== null && endDate !== null && periods === undefined) { - dates = rangeStartEnd(startDate, endDate, offset); - } else if (startDate !== null && periods !== undefined) { - dates = rangeStartPeriods(startDate, periods, offset); - } else if (endDate !== null && periods !== undefined) { - dates = rangeEndPeriods(endDate, periods, offset); - } else { - throw new Error("date_range: provide at least two of 'start', 'end', 'periods'"); + return rangeStartEnd(startDate, endDate, offset); + } + if (startDate !== null && periods !== undefined) { + return rangeStartPeriods(startDate, periods, offset); } + if (endDate !== null && periods !== undefined) { + return rangeEndPeriods(endDate, periods, offset); + } + throw new Error("date_range: provide at least two of 'start', 'end', 'periods'"); +} +function buildRange(options: DateRangeOptions, defaultFreq: DateRangeFreq): DatetimeIndex { + const { start, end, periods, normalize = false, name = null } = options; + const freq = options.freq ?? defaultFreq; + const offset = freqToOffset(freq); + const { startDate, endDate } = resolveBoundDates(start, end, normalize); + const dates = selectDates(startDate, endDate, periods, offset); return DatetimeIndex.fromDates(dates, name); } diff --git a/src/core/frame.ts b/src/core/frame.ts index ddb641a1..ec18d144 100644 --- a/src/core/frame.ts +++ b/src/core/frame.ts @@ -19,12 +19,12 @@ import { DataFrameGroupBy } from "../groupby/index.ts"; import type { Label, Scalar } from "../types.ts"; -import { EWM } from "../window/ewm.ts"; -import type { EwmOptions } from "../window/ewm.ts"; -import { Expanding } from "../window/expanding.ts"; -import type { ExpandingOptions } from "../window/expanding.ts"; -import { Rolling } from "../window/rolling.ts"; -import type { RollingOptions } from "../window/rolling.ts"; +import { EWM } from "../window/index.ts"; +import type { EwmOptions } from "../window/index.ts"; +import { Expanding } from "../window/index.ts"; +import type { ExpandingOptions } from "../window/index.ts"; +import { Rolling } from "../window/index.ts"; +import type { RollingOptions } from "../window/index.ts"; import { Index } from "./base-index.ts"; import { RangeIndex } from "./range-index.ts"; import { Series } from "./series.ts"; @@ -581,6 +581,43 @@ export class DataFrame { yield* this._columns.entries(); } + /** + * Iterate over DataFrame rows as objects with an `Index` property plus + * one property per column — mirrors `DataFrame.itertuples()`. + * + * Unlike pandas' named-tuples (which are positional), JavaScript does not + * have positional tuples with named fields, so each row is returned as a + * plain object `{ Index: label, col1: val, col2: val, ... }`. The `Index` + * key matches the pandas `itertuples(name="Pandas")` default. + * + * @param index - Whether to include the row index as the `Index` field. + * Default `true`. + * @param name - Unused (kept for API parity); TypeScript records have no + * class name. + * + * @example + * ```ts + * const df = new DataFrame({ a: [1, 2], b: ["x", "y"] }); + * for (const row of df.itertuples()) { + * console.log(row); // { Index: 0, a: 1, b: "x" } then { Index: 1, a: 2, b: "y" } + * } + * ``` + */ + *itertuples(index = true, _name?: string): IterableIterator> { + const nRows = this.index.size; + const colNames = this.columns.values as readonly string[]; + for (let i = 0; i < nRows; i++) { + const row: Record = {}; + if (index) { + row["Index"] = this.index.at(i) as Scalar; + } + for (const name of colNames) { + row[name] = this.col(name).iat(i); + } + yield row; + } + } + /** Iterate over `(rowLabel, rowSeries)` pairs — mirrors `DataFrame.iterrows()`. */ *iterrows(): IterableIterator<[Label, Series]> { const nRows = this.index.size; diff --git a/src/core/natsort.ts b/src/core/natsort.ts index d0112d3e..260d10d6 100644 --- a/src/core/natsort.ts +++ b/src/core/natsort.ts @@ -88,7 +88,13 @@ function cmpTokens(a: Token, b: Token, ignoreCase: boolean): number { if (typeof a === "string" && typeof b === "string") { const la = ignoreCase ? a.toLowerCase() : a; const lb = ignoreCase ? b.toLowerCase() : b; - return la < lb ? -1 : la > lb ? 1 : 0; + if (la < lb) { + return -1; + } + if (la > lb) { + return 1; + } + return 0; } // Mixed: digit tokens sort before string tokens return typeof a === "number" ? -1 : 1; @@ -205,6 +211,27 @@ export function natSortKey( return tokens.map((t) => (typeof t === "string" ? t.toLowerCase() : t)); } +/** Compare two pre-tokenised keys for use inside `natArgSort`'s sort callback. */ +function cmpTokenArrays(ta: readonly Token[], tb: readonly Token[], reverse: boolean): number { + const len = Math.min(ta.length, tb.length); + for (let k = 0; k < len; k++) { + const taToken = ta[k]; + const tbToken = tb[k]; + if (taToken === undefined || tbToken === undefined) { + break; + } + const c = cmpTokens(taToken, tbToken, false); // already case-folded + if (c !== 0) { + return reverse ? -c : c; + } + } + const lc = ta.length - tb.length; + if (lc === 0) { + return 0; + } + return reverse ? -lc : lc; +} + /** * Return the integer permutation that would sort `arr` in natural order. * @@ -229,23 +256,7 @@ export function natArgSort(arr: readonly string[], options: NatSortOptions = {}) if (ta === undefined || tb === undefined) { throw new RangeError("natArgSort: index out of bounds"); } - const len = Math.min(ta.length, tb.length); - for (let k = 0; k < len; k++) { - const taToken = ta[k]; - const tbToken = tb[k]; - if (taToken === undefined || tbToken === undefined) { - break; - } - const c = cmpTokens(taToken, tbToken, false); // already case-folded - if (c !== 0) { - return reverse ? -c : c; - } - } - const lc = ta.length - tb.length; - if (lc === 0) { - return 0; - } - return reverse ? -lc : lc; + return cmpTokenArrays(ta, tb, reverse); }); return indices; } diff --git a/src/core/options.ts b/src/core/options.ts new file mode 100644 index 00000000..628b5cce --- /dev/null +++ b/src/core/options.ts @@ -0,0 +1,349 @@ +/** + * options — pandas-compatible options system for tsb. + * + * Mirrors `pandas.core.config_init` / `pandas.get_option` / `pandas.set_option` etc. + * + * Supports: + * - `getOption(key)` / `setOption(key, val)` / `resetOption(key)` + * - `describeOption(key?)` — pretty-print option documentation + * - `optionContext(key, val, ...)` — context-manager-like scoped override (async-safe via manual enter/exit) + * - `registerOption(key, defaultVal, doc?, validator?)` — extend the registry + * - `options` — a deep Proxy object providing `options.display.maxRows` style access + * + * @example + * ```ts + * import { getOption, setOption, resetOption, options } from "tsb"; + * getOption("display.max_rows"); // 60 + * setOption("display.max_rows", 20); + * options.display.max_rows; // 20 + * resetOption("display.max_rows"); + * ``` + * + * @module + */ + +// ─── Types ──────────────────────────────────────────────────────────────────── + +/** Primitive option value types. */ +export type OptionValue = string | number | boolean | null; + +/** Validator function — returns an error string on invalid value, or undefined. */ +export type OptionValidator = (val: OptionValue) => string | undefined; + +interface OptionEntry { + defaultValue: OptionValue; + currentValue: OptionValue; + doc: string; + validator?: OptionValidator; +} + +// ─── Internal registry ──────────────────────────────────────────────────────── + +const _registry = new Map(); + +function _normalizeKey(key: string): string { + return key.toLowerCase().replace(/-/g, "_"); +} + +function _lookupEntry(key: string): OptionEntry { + const k = _normalizeKey(key); + const entry = _registry.get(k); + if (entry === undefined) { + throw new Error( + `No such option: ${key}. Available options:\n${[..._registry.keys()].sort().join("\n")}`, + ); + } + return entry; +} + +// ─── Public API ─────────────────────────────────────────────────────────────── + +/** + * Register a new option with a key, default value, optional documentation, and + * optional validator. Throws if the key is already registered. + */ +export function registerOption( + key: string, + defaultValue: OptionValue, + doc = "", + validator?: OptionValidator, +): void { + const k = _normalizeKey(key); + if (_registry.has(k)) { + throw new Error(`Option already registered: ${key}`); + } + const entry: OptionEntry = { defaultValue, currentValue: defaultValue, doc }; + if (validator !== undefined) { + entry.validator = validator; + } + _registry.set(k, entry); +} + +/** + * Retrieve the current value of an option by its dot-separated key. + * Raises if the key does not exist. + */ +export function getOption(key: string): OptionValue { + return _lookupEntry(key).currentValue; +} + +/** + * Set an option value. Validates with the registered validator, if any. + * Raises if the key does not exist or the value is invalid. + */ +export function setOption(key: string, value: OptionValue): void { + const entry = _lookupEntry(key); + if (entry.validator !== undefined) { + const err = entry.validator(value); + if (err !== undefined) { + throw new Error(`Invalid value for option '${key}': ${err}`); + } + } + entry.currentValue = value; +} + +/** + * Reset one or all options to their default values. + * Pass `"all"` to reset every option. + */ +export function resetOption(key: string): void { + if (_normalizeKey(key) === "all") { + for (const entry of _registry.values()) { + entry.currentValue = entry.defaultValue; + } + return; + } + const entry = _lookupEntry(key); + entry.currentValue = entry.defaultValue; +} + +/** + * Return a formatted string describing one or all options. + * Pass a prefix to narrow results (e.g., `"display"`). + */ +export function describeOption(key?: string): string { + const prefix = key !== undefined ? _normalizeKey(key) : ""; + const matches: string[] = []; + for (const [k, entry] of _registry.entries()) { + if (prefix === "" || k === prefix || k.startsWith(`${prefix}.`)) { + matches.push( + `${k}\n Default : ${String(entry.defaultValue)}\n Current : ${String(entry.currentValue)}\n ${entry.doc}`, + ); + } + } + if (matches.length === 0) { + throw new Error(`No option matching: ${key ?? "(all)"}`); + } + return matches.join("\n\n"); +} + +// ─── Option Context ─────────────────────────────────────────────────────────── + +/** A scoped override token returned by {@link optionContext}. */ +export interface OptionContextToken { + /** Apply the overrides (call before the scoped block). */ + enter(): void; + /** Restore the previous values (call after the scoped block). */ + exit(): void; +} + +/** + * Create a scoped override context for one or more options. + * Pass alternating key–value pairs. + * + * @example + * ```ts + * const ctx = optionContext("display.max_rows", 5, "display.max_columns", 3); + * ctx.enter(); + * // ... code that uses options ... + * ctx.exit(); + * ``` + */ +export function optionContext( + ...keyValuePairs: readonly (OptionValue | string)[] +): OptionContextToken { + if (keyValuePairs.length % 2 !== 0) { + throw new Error("optionContext requires pairs of (key, value) arguments"); + } + const pairs: Array<{ key: string; value: OptionValue }> = []; + for (let i = 0; i < keyValuePairs.length; i += 2) { + const k = keyValuePairs[i]; + const v = keyValuePairs[i + 1]; + if (typeof k !== "string") { + throw new Error("optionContext keys must be strings"); + } + pairs.push({ key: k, value: v as OptionValue }); + } + + const saved = new Map(); + + return { + enter(): void { + for (const { key, value } of pairs) { + saved.set(key, getOption(key)); + setOption(key, value); + } + }, + exit(): void { + for (const { key } of [...pairs].reverse()) { + const prev = saved.get(key); + if (prev !== undefined) { + setOption(key, prev); + } + } + saved.clear(); + }, + }; +} + +// ─── `options` Proxy ────────────────────────────────────────────────────────── + +export interface OptionsProxy extends Record {} + +function _makeProxy(prefix: string): OptionsProxy { + return new Proxy({} as OptionsProxy, { + get(_target, prop: string | symbol): OptionsProxy | OptionValue { + if (typeof prop !== "string") { + return undefined as unknown as OptionValue; + } + const key = prefix !== "" ? `${prefix}.${prop}` : prop; + // If there is a direct match, return its value + const k = _normalizeKey(key); + if (_registry.has(k)) { + return _registry.get(k)!.currentValue; + } + // Otherwise return a nested proxy for deeper access + return _makeProxy(k); + }, + set(_target, prop: string | symbol, value: unknown): boolean { + if (typeof prop !== "string") { + return false; + } + const key = prefix !== "" ? `${prefix}.${prop}` : prop; + setOption(key, value as OptionValue); + return true; + }, + }); +} + +/** + * A deeply-nested proxy object that mirrors the options registry. + * Supports both read and write access. + * + * @example + * ```ts + * options.display.max_rows; // 60 + * options.display.max_rows = 20; + * ``` + */ +export const options: OptionsProxy = _makeProxy(""); + +// ─── Built-in options ───────────────────────────────────────────────────────── + +function _posInt(name: string): OptionValidator { + return (v): string | undefined => + typeof v === "number" && Number.isInteger(v) && v >= 0 + ? undefined + : `${name} must be a non-negative integer`; +} + +function _bool(name: string): OptionValidator { + return (v): string | undefined => + typeof v === "boolean" ? undefined : `${name} must be a boolean`; +} + +function _oneOf(name: string, choices: readonly OptionValue[]): OptionValidator { + return (v): string | undefined => + choices.includes(v) ? undefined : `${name} must be one of: ${choices.map(String).join(", ")}`; +} + +// display.* +registerOption( + "display.max_rows", + 60, + "Maximum number of rows to display.", + _posInt("display.max_rows"), +); +registerOption( + "display.min_rows", + 10, + "Minimum rows displayed when the frame is truncated.", + _posInt("display.min_rows"), +); +registerOption( + "display.max_columns", + 20, + "Maximum number of columns to display.", + _posInt("display.max_columns"), +); +registerOption( + "display.max_colwidth", + 50, + "Maximum width of column values (characters) before truncation.", + _posInt("display.max_colwidth"), +); +registerOption("display.width", 80, "Terminal width used for wrapping.", _posInt("display.width")); +registerOption( + "display.precision", + 6, + "Floating-point display precision (significant digits).", + _posInt("display.precision"), +); +registerOption( + "display.show_dimensions", + true, + "Whether to show DataFrame dimensions at the end of repr.", + _bool("display.show_dimensions"), +); +registerOption( + "display.unicode.east_asian_width", + false, + "Whether to account for east-Asian character widths.", + _bool("display.unicode.east_asian_width"), +); +registerOption( + "display.float_format", + null, + "Callable used to format floating-point values; null means use default repr.", +); +registerOption( + "display.colheader_justify", + "right", + "Justify column headers: 'left' or 'right'.", + _oneOf("display.colheader_justify", ["left", "right"]), +); + +// mode.* +registerOption( + "mode.chained_assignment", + "warn", + "Controls SettingWithCopyWarning: 'raise', 'warn', or null.", + _oneOf("mode.chained_assignment", ["raise", "warn", null]), +); +registerOption( + "mode.dtype_backend", + "numpy", + "Default dtype backend: 'numpy' or 'arrow'.", + _oneOf("mode.dtype_backend", ["numpy", "arrow"]), +); +registerOption("mode.use_inf_as_na", false, "Treat infinity as NA.", _bool("mode.use_inf_as_na")); +registerOption( + "mode.copy_on_write", + false, + "Enable copy-on-write semantics.", + _bool("mode.copy_on_write"), +); + +// compute.* +registerOption( + "compute.use_bottleneck", + false, + "Use the bottleneck library for numeric operations.", + _bool("compute.use_bottleneck"), +); +registerOption( + "compute.use_numexpr", + false, + "Use numexpr for large array eval().", + _bool("compute.use_numexpr"), +); diff --git a/src/core/pd_api.ts b/src/core/pd_api.ts new file mode 100644 index 00000000..0964d2e0 --- /dev/null +++ b/src/core/pd_api.ts @@ -0,0 +1,108 @@ +/** + * pd_api — the `pd.api` namespace object, mirroring `pandas.api`. + * + * Provides `api.types` (type-checking predicates) sub-namespace, analogous to + * `pandas.api.types`. + * + * @example + * ```ts + * import { api } from "tsb"; + * api.types.isScalar(42); // true + * api.types.isNumericDtype("float64"); // true + * api.types.isListLike([1, 2, 3]); // true + * ``` + * + * @module + */ + +import { + isArrayLike, + isBigInt, + isBool, + isBoolDtype, + isCategoricalDtype, + isComplexDtype, + isDate, + isDatetimeDtype, + isDictLike, + isExtensionArrayDtype, + isFloat, + isFloatDtype, + isHashable, + isInteger, + isIntegerDtype, + isIntervalDtype, + isIterator, + isListLike, + isMissing, + isNumber, + isNumericDtype, + isObjectDtype, + isPeriodDtype, + isScalar, + isSignedIntegerDtype, + isStringDtype, + isTimedeltaDtype, + isUnsignedIntegerDtype, +} from "./api_types.ts"; + +// ─── api.types ──────────────────────────────────────────────────────────────── + +/** + * The `api.types` sub-namespace — mirrors `pandas.api.types`. + * + * Contains predicates for both runtime values and dtypes. + */ +export const apiTypes = { + // Value-level predicates + isScalar, + isListLike, + isArrayLike, + isDictLike, + isIterator, + isNumber, + isBool, + isFloat, + isInteger, + isBigInt, + isMissing, + isHashable, + isDate, + + // Dtype-level predicates + isNumericDtype, + isIntegerDtype, + isSignedIntegerDtype, + isUnsignedIntegerDtype, + isFloatDtype, + isBoolDtype, + isStringDtype, + isDatetimeDtype, + isTimedeltaDtype, + isCategoricalDtype, + isObjectDtype, + isComplexDtype, + isExtensionArrayDtype, + isPeriodDtype, + isIntervalDtype, +} as const; + +export type ApiTypes = typeof apiTypes; + +// ─── api namespace ──────────────────────────────────────────────────────────── + +/** + * The top-level `api` namespace object, mirroring `pandas.api`. + * + * @example + * ```ts + * import { api } from "tsb"; + * api.types.isScalar(42); + * ``` + */ +export const api = { + /** Type-checking predicates — mirrors `pandas.api.types`. */ + types: apiTypes, +} as const; + +export type Api = typeof api; diff --git a/src/core/series.ts b/src/core/series.ts index b4328084..1b39cffc 100644 --- a/src/core/series.ts +++ b/src/core/series.ts @@ -1083,11 +1083,111 @@ export class Series { } /** - * Apply a function to each value, returning a new Series. + * Apply a mapper to each value, returning a new Series. + * + * Three forms are supported (mirroring `pandas.Series.map`): + * + * 1. **Function** `(value, index, pos) => U` — called for every element. + * 2. **Plain object / Record** `{ [key: string]: U }` — each value is looked + * up by its string representation; missing keys map to `null`. + * 3. **Series mapper** — each value is looked up in the mapper Series by + * index label; missing labels map to `null`. + * 4. **ES6 Map** — direct `Map` lookup; missing keys map to `null`. + * + * When `naAction` is `"ignore"`, existing `null`/`undefined`/`NaN` values + * are passed through unchanged instead of being looked up in the mapper. + * The option is only meaningful for dict/Series/Map forms. + * + * @example + * ```ts + * import { Series } from "tsb"; + * const s = new Series({ data: [1, 2, 3], name: "x" }); + * s.map(v => v * 10); // [10, 20, 30] + * s.map({ "1": "one", "2": "two" }); // ["one", "two", null] + * ``` */ - map(fn: (value: T, index: Label, pos: number) => U): Series { - return new Series({ - data: this._values.map((v, i) => fn(v, this.index.at(i), i)), + map(fn: (value: T, index: Label, pos: number) => U): Series; + map( + mapper: Record, + options?: { naAction?: "ignore" | null }, + ): Series; + map( + mapper: Series, + options?: { naAction?: "ignore" | null }, + ): Series; + map( + mapper: Map, + options?: { naAction?: "ignore" | null }, + ): Series; + map( + mapperOrFn: + | ((value: T, index: Label, pos: number) => U) + | Record + | Series + | Map, + options?: { naAction?: "ignore" | null }, + ): Series | Series { + const naAction = options?.naAction ?? null; + + if (typeof mapperOrFn === "function") { + return new Series({ + data: this._values.map((v, i) => + (mapperOrFn as (value: T, index: Label, pos: number) => U)(v, this.index.at(i), i), + ), + index: this.index, + name: this.name, + }); + } + + // Helper: is a value "NA" for the purposes of naAction + const isNa = (v: Scalar): boolean => + v === null || v === undefined || (typeof v === "number" && Number.isNaN(v)); + + if (mapperOrFn instanceof Map) { + const m = mapperOrFn as Map; + const data = this._values.map((v): U | null => { + if (naAction === "ignore" && isNa(v)) { + return v as unknown as U | null; + } + const result = m.get(v); + return result !== undefined ? result : null; + }); + return new Series({ + data, + index: this.index, + name: this.name, + }); + } + + if (mapperOrFn instanceof Series) { + const seriesMapper = mapperOrFn as Series; + const data = this._values.map((v): U | null => { + if (naAction === "ignore" && isNa(v)) { + return v as unknown as U | null; + } + if (!seriesMapper.index.contains(v as Label)) { + return null; + } + return seriesMapper.loc(v as Label) as U | null; + }); + return new Series({ + data, + index: this.index, + name: this.name, + }); + } + + // Plain object / Record + const rec = mapperOrFn as Record; + const data = this._values.map((v): U | null => { + if (naAction === "ignore" && isNa(v)) { + return v as unknown as U | null; + } + const key = String(v); + return Object.prototype.hasOwnProperty.call(rec, key) ? (rec[key] as U) : null; + }); + return new Series({ + data, index: this.index, name: this.name, }); @@ -1214,6 +1314,34 @@ export class Series { groupby(by: readonly Scalar[] | Series): SeriesGroupBy { return new SeriesGroupBy(this as Series, by); } + + // ─── items / iteritems ──────────────────────────────────────────────────── + + /** + * Lazily iterate over `(label, value)` pairs — mirrors `Series.items()`. + * + * @example + * ```ts + * const s = new Series({ data: [10, 20], index: ["a", "b"] }); + * for (const [label, value] of s.items()) { + * console.log(label, value); // "a" 10 then "b" 20 + * } + * ``` + */ + *items(): IterableIterator<[Label, T]> { + const n = this.index.size; + for (let i = 0; i < n; i++) { + yield [this.index.at(i) as Label, this.iat(i) as T]; + } + } + + /** + * Alias for `items()` — mirrors `Series.iteritems()` (deprecated in pandas + * 1.5 but still widely used). + */ + *iteritems(): IterableIterator<[Label, T]> { + yield* this.items(); + } } function isIndexLike(v: unknown): v is Index