From cea51599e9e30da9a4bd38fb704fdbd75ab19400 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 10:04:58 +0000 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=93=9D=20Add=20docstrings=20to=20`dev?= =?UTF-8?q?/smartart`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Docstrings generation was requested by @harumiWeb. * https://github.com/harumiWeb/exstruct/pull/30#issuecomment-3694621744 The following files were modified: * `scripts/gen_json_schema.py` * `src/exstruct/core/shapes.py` * `src/exstruct/io/__init__.py` * `tests/com/test_shapes_extraction.py` * `tests/core/test_mode_output.py` * `tests/core/test_shapes_positions_dummy.py` * `tests/core/test_shapes_smartart_utils.py` * `tests/io/test_print_area_views.py` * `tests/models/test_models_export.py` * `tests/models/test_models_validation.py` --- scripts/gen_json_schema.py | 11 +- src/exstruct/core/shapes.py | 118 +++++++++++++++++++--- src/exstruct/io/__init__.py | 40 +++++++- tests/com/test_shapes_extraction.py | 16 ++- tests/core/test_mode_output.py | 8 +- tests/core/test_shapes_positions_dummy.py | 32 +++++- tests/core/test_shapes_smartart_utils.py | 28 ++++- tests/io/test_print_area_views.py | 15 ++- tests/models/test_models_export.py | 7 +- tests/models/test_models_validation.py | 7 +- 10 files changed, 255 insertions(+), 27 deletions(-) diff --git a/scripts/gen_json_schema.py b/scripts/gen_json_schema.py index a848939..02bb836 100644 --- a/scripts/gen_json_schema.py +++ b/scripts/gen_json_schema.py @@ -39,7 +39,14 @@ def _write_schema(name: str, model: type[BaseModel], output_dir: Path) -> Path: def main() -> int: - """Generate JSON Schemas for ExStruct public models.""" + """ + Generate JSON Schema files for ExStruct's public Pydantic models into the repository 'schemas' directory. + + Writes one JSON Schema file per public model into the 'schemas' folder at the project root. + + Returns: + exit_code (int): 0 on success. + """ project_root = Path(__file__).resolve().parent.parent output_dir = project_root / "schemas" targets: dict[str, type[BaseModel]] = { @@ -62,4 +69,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) + raise SystemExit(main()) \ No newline at end of file diff --git a/src/exstruct/core/shapes.py b/src/exstruct/core/shapes.py index 1831937..e0ba3ea 100644 --- a/src/exstruct/core/shapes.py +++ b/src/exstruct/core/shapes.py @@ -12,14 +12,33 @@ def compute_line_angle_deg(w: float, h: float) -> float: - """Compute clockwise angle in Excel coordinates where 0 deg points East.""" + """ + Compute the clockwise angle (in degrees) in Excel coordinates where 0° points East. + + Parameters: + w (float): Horizontal delta (width, positive to the right). + h (float): Vertical delta (height, positive downward). + + Returns: + float: Angle in degrees measured clockwise from East (e.g., 0° = East, 90° = South). + """ return math.degrees(math.atan2(h, w)) % 360.0 def angle_to_compass( angle: float, ) -> Literal["E", "SE", "S", "SW", "W", "NW", "N", "NE"]: - """Convert angle to 8-point compass direction (0deg=E, 45deg=NE, 90deg=N, etc).""" + """ + Map an angle in degrees to one of eight compass directions. + + The angle is interpreted with 0 degrees at East and increasing values rotating counterclockwise (45 -> NE, 90 -> N). + + Parameters: + angle (float): Angle in degrees. + + Returns: + str: One of `"E"`, `"SE"`, `"S"`, `"SW"`, `"W"`, `"NW"`, `"N"`, or `"NE"` corresponding to the nearest 8-point compass direction. + """ dirs = ["E", "NE", "N", "NW", "W", "SW", "S", "SE"] idx = int(((angle + 22.5) % 360) // 45) return cast(Literal["E", "SE", "S", "SW", "W", "NW", "N", "NE"], dirs[idx]) @@ -28,7 +47,18 @@ def angle_to_compass( def coord_to_cell_by_edges( row_edges: list[float], col_edges: list[float], x: float, y: float ) -> str | None: - """Estimate cell address from coordinates and cumulative edges; return None if out of range.""" + """ + Estimate the Excel A1-style cell that contains a point given cumulative row and column edge coordinates. + + Parameters: + row_edges (list[float]): Monotonic list of cumulative vertical edges (top-to-bottom). Consecutive entries define row spans. + col_edges (list[float]): Monotonic list of cumulative horizontal edges (left-to-right). Consecutive entries define column spans. + x (float): Horizontal coordinate (same coordinate system as col_edges). + y (float): Vertical coordinate (same coordinate system as row_edges). + + Returns: + str | None: A1-style cell address (e.g., "B3") if the point falls inside the grid; `None` if the point is outside the provided edge ranges. Intervals are treated as left-inclusive and right-exclusive: [edge_i, edge_{i+1}). + """ def find_index(edges: list[float], pos: float) -> int | None: for i in range(1, len(edges)): @@ -82,10 +112,18 @@ def _should_include_shape( output_mode: str = "standard", ) -> bool: """ - Decide whether to emit a shape given output mode. - - standard: emit if text exists OR the shape is an arrow/line/connector. - - light: suppress shapes entirely (handled upstream, but guard defensively). - - verbose: include all (except already-filtered chart/comment/picture/form controls). + Determine whether a shape should be included in the output based on its properties and the selected output mode. + + Modes: + - "light": always exclude shapes. + - "standard": include when the shape has text or represents a relationship (line/connector). + - "verbose": include all shapes (other global exclusions are handled elsewhere). + + Parameters: + output_mode (str): One of "light", "standard", or "verbose"; controls inclusion rules. + + Returns: + bool: `True` if the shape should be emitted, `False` otherwise. """ if output_mode == "light": return False @@ -142,7 +180,12 @@ class _SmartArtLike(Protocol): def _shape_has_smartart(shp: xw.Shape) -> bool: - """Return True if the shape exposes SmartArt content.""" + """ + Determine whether a shape exposes SmartArt content. + + Returns: + bool: `True` if the shape exposes SmartArt (i.e., has an accessible `HasSmartArt` attribute), `False` otherwise. + """ try: api = shp.api except Exception: @@ -154,7 +197,12 @@ def _shape_has_smartart(shp: xw.Shape) -> bool: def _get_smartart_layout_name(smartart: _SmartArtLike | None) -> str: - """Return SmartArt layout name or a fallback label.""" + """ + Get the SmartArt layout name or "Unknown" if it cannot be determined. + + Returns: + layout_name (str): The layout name from `smartart.Layout.Name`, or "Unknown" when `smartart` is None or the name cannot be retrieved. + """ if smartart is None: return "Unknown" try: @@ -168,7 +216,15 @@ def _get_smartart_layout_name(smartart: _SmartArtLike | None) -> str: def _collect_smartart_node_info( smartart: _SmartArtLike | None, ) -> list[tuple[int, str]]: - """Collect (level, text) pairs from SmartArt nodes.""" + """ + Extract a list of (level, text) tuples for each node present in the given SmartArt. + + Parameters: + smartart (_SmartArtLike | None): A SmartArt-like COM object or `None`. If `None` or inaccessible, no nodes are collected. + + Returns: + list[tuple[int, str]]: A list of tuples where each tuple is (node level, node text). Returns an empty list if the SmartArt is `None`, inaccessible, or if nodes lack a numeric level. + """ nodes_info: list[tuple[int, str]] = [] if smartart is None: return nodes_info @@ -194,7 +250,12 @@ def _collect_smartart_node_info( def _get_smartart_node_level(node: _SmartArtNodeLike) -> int | None: - """Return SmartArt node level or None when unavailable.""" + """ + Get the numerical level of a SmartArt node. + + Returns: + int | None: The node's level as an integer, or `None` if the level is missing or cannot be converted to an integer. + """ try: return int(node.Level) except Exception: @@ -202,7 +263,17 @@ def _get_smartart_node_level(node: _SmartArtNodeLike) -> int | None: def _build_smartart_tree(nodes_info: list[tuple[int, str]]) -> list[SmartArtNode]: - """Build nested SmartArtNode roots from flat (level, text) tuples.""" + """ + Build a nested tree of SmartArtNode objects from a flat list of (level, text) tuples. + + Parameters: + nodes_info (list[tuple[int, str]]): Ordered tuples where each tuple is (level, text); + `level` is the hierarchical depth (integer) and `text` is the node label. + + Returns: + roots (list[SmartArtNode]): Top-level SmartArtNode instances whose `kids` lists + contain their nested child nodes according to the provided levels. + """ roots: list[SmartArtNode] = [] stack: list[tuple[int, SmartArtNode]] = [] for level, text in nodes_info: @@ -218,7 +289,15 @@ def _build_smartart_tree(nodes_info: list[tuple[int, str]]) -> list[SmartArtNode def _extract_smartart_nodes(smartart: _SmartArtLike | None) -> list[SmartArtNode]: - """Extract SmartArt nodes as nested roots.""" + """ + Convert a SmartArt COM object into a list of root SmartArtNode trees. + + Parameters: + smartart (_SmartArtLike | None): SmartArt-like COM object to extract nodes from; pass `None` to produce an empty list. + + Returns: + list[SmartArtNode]: Root nodes representing the hierarchical SmartArt structure (each node contains its text and children). + """ nodes_info = _collect_smartart_node_info(smartart) return _build_smartart_tree(nodes_info) @@ -226,7 +305,16 @@ def _extract_smartart_nodes(smartart: _SmartArtLike | None) -> list[SmartArtNode def get_shapes_with_position( # noqa: C901 workbook: Book, mode: str = "standard" ) -> dict[str, list[Shape | Arrow | SmartArt]]: - """Scan shapes in a workbook and return per-sheet shape lists with position info.""" + """ + Scan all shapes in each worksheet and collect their positional and metadata information. + + Parameters: + workbook (Book): The xlwings workbook to scan. + mode (str): Output detail level; "light" skips most shapes, "standard" includes shapes with text or relationships, and "verbose" includes full size/rotation details. + + Returns: + dict[str, list[Shape | Arrow | SmartArt]]: Mapping of sheet name to a list of collected shape objects (Shape, Arrow, or SmartArt) containing position (left/top), optional size (width/height), textual content, and other captured metadata (ids, directions, connections, layout/nodes for SmartArt). + """ shape_data: dict[str, list[Shape | Arrow | SmartArt]] = {} for sheet in workbook.sheets: shapes: list[Shape | Arrow | SmartArt] = [] @@ -430,4 +518,4 @@ def get_shapes_with_position( # noqa: C901 if end_name: shape_obj.end_id = name_to_id.get(end_name) shape_data[sheet.name] = shapes - return shape_data + return shape_data \ No newline at end of file diff --git a/src/exstruct/io/__init__.py b/src/exstruct/io/__init__.py index e2ad37a..1ea419c 100644 --- a/src/exstruct/io/__init__.py +++ b/src/exstruct/io/__init__.py @@ -30,7 +30,17 @@ def dict_without_empty_values(obj: object) -> JsonStructure: - """Recursively drop empty values from nested structures.""" + """ + Remove None, empty string, empty list, and empty dict values from a nested structure or supported model object. + + Recursively processes dicts, lists, and supported model types (WorkbookData, CellRow, Chart, PrintArea, PrintAreaView, Shape, Arrow, SmartArt). Model instances are converted to dictionaries with None fields excluded before recursive cleaning. Values considered empty and removed are: `None`, `""` (empty string), `[]` (empty list), and `{}` (empty dict). + + Parameters: + obj (object): A value to clean; may be a dict, list, scalar, or one of the supported model instances. + + Returns: + JsonStructure: The input structure with empty values removed, preserving other values and nesting. + """ if isinstance(obj, dict): return { k: dict_without_empty_values(v) @@ -173,13 +183,37 @@ def _area_to_px_rect( def _rects_overlap(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> bool: - """Return True if rectangles (l, t, r, b) overlap.""" + """ + Determine whether two axis-aligned rectangles intersect (overlap in area). + + Parameters: + a (tuple[int, int, int, int]): Rectangle A as (left, top, right, bottom). + b (tuple[int, int, int, int]): Rectangle B as (left, top, right, bottom). + + Notes: + Rectangles are treated as half-open in this context: if they only touch at edges or corners, they do not count as overlapping. + + Returns: + bool: `True` if the rectangles have a non-zero-area intersection, `False` otherwise. + """ return not (a[2] <= b[0] or a[0] >= b[2] or a[3] <= b[1] or a[1] >= b[3]) def _filter_shapes_to_area( shapes: list[Shape | Arrow | SmartArt], area: PrintArea ) -> list[Shape | Arrow | SmartArt]: + """ + Filter drawable shapes to those that intersect the given print area. + + Shapes and the print area are compared in approximate pixel coordinates. Shapes that have both width and height are included when their bounding rectangle overlaps the area. Shapes with unknown size (width or height is None) are treated as a point at their left/top coordinates and included only if that point lies inside the area. + + Parameters: + shapes (list[Shape | Arrow | SmartArt]): Drawable objects with `l`, `t`, `w`, `h` coordinates. + area (PrintArea): Cell-based print area that will be converted to an approximate pixel rectangle. + + Returns: + list[Shape | Arrow | SmartArt]: Subset of `shapes` whose geometry intersects the print area. + """ area_rect = _area_to_px_rect(area) filtered: list[Shape | Arrow | SmartArt] = [] for shp in shapes: @@ -510,4 +544,4 @@ def save_sheets( "serialize_workbook", "_require_yaml", "_require_toon", -] +] \ No newline at end of file diff --git a/tests/com/test_shapes_extraction.py b/tests/com/test_shapes_extraction.py index be31e2d..c7127d1 100644 --- a/tests/com/test_shapes_extraction.py +++ b/tests/com/test_shapes_extraction.py @@ -64,6 +64,15 @@ def _make_workbook_with_shapes(path: Path) -> None: def test_図形の種別とテキストが抽出される(tmp_path: Path) -> None: + """ + Verifies extraction of shape types, texts, IDs, and uniqueness from a workbook containing various shapes. + + Creates a workbook with a rectangle, an oval, a line, a nested group, and a connector, then extracts shapes from "Sheet1" and asserts: + - a shape with text "rect" is an AutoShape, has non-negative left/top coordinates, and a positive id; + - a nested child with text "inner" is not reported as a Group and has a positive id; + - all emitted shape ids are unique; + - no AutoShape without text is emitted in standard mode. + """ _ensure_excel() path = tmp_path / "shapes.xlsx" _make_workbook_with_shapes(path) @@ -92,6 +101,11 @@ def test_図形の種別とテキストが抽出される(tmp_path: Path) -> Non def test_線図形の方向と矢印情報が抽出される(tmp_path: Path) -> None: + """ + Verifies that a line shape's direction and arrow style information is extracted correctly from a workbook. + + Creates a workbook containing shapes, extracts shapes from "Sheet1", finds an Arrow with a begin or end arrow style, and asserts its direction is "E". + """ _ensure_excel() path = tmp_path / "lines.xlsx" _make_workbook_with_shapes(path) @@ -133,4 +147,4 @@ def test_コネクターの接続元と接続先が抽出される(tmp_path: Pat # Connected shape ids should correspond to some emitted shapes' id. shape_ids = {s.id for s in shapes if s.id is not None} assert conn.begin_id in shape_ids - assert conn.end_id in shape_ids + assert conn.end_id in shape_ids \ No newline at end of file diff --git a/tests/core/test_mode_output.py b/tests/core/test_mode_output.py index 06e5930..f014d71 100644 --- a/tests/core/test_mode_output.py +++ b/tests/core/test_mode_output.py @@ -14,6 +14,12 @@ def _make_basic_book(path: Path) -> None: + """ + Create and save a simple Excel workbook with one sheet named "Sheet1" containing "v1" in A1 and "v2" in B1. + + Parameters: + path (Path): Filesystem path where the workbook will be saved. + """ wb = Workbook() ws = wb.active ws.title = "Sheet1" @@ -181,4 +187,4 @@ def test_CLI_defaults_to_stdout(tmp_path: Path) -> None: ] result = subprocess.run(cmd, capture_output=True, text=True) assert result.returncode == 0 - assert '"book_name": "book.xlsx"' in result.stdout + assert '"book_name": "book.xlsx"' in result.stdout \ No newline at end of file diff --git a/tests/core/test_shapes_positions_dummy.py b/tests/core/test_shapes_positions_dummy.py index 999e70b..ecbaa6b 100644 --- a/tests/core/test_shapes_positions_dummy.py +++ b/tests/core/test_shapes_positions_dummy.py @@ -43,6 +43,12 @@ def Line(self) -> _DummyLine: @property def Rotation(self) -> float: + """ + Get the shape's rotation angle. + + Returns: + rotation (float): Rotation angle in degrees. + """ return self.rotation @@ -52,18 +58,42 @@ class _DummyApiSmartArt: @property def Type(self) -> int: + """ + Get the shape's type identifier. + + Returns: + shape_type (int): Integer identifier for the shape type. + """ return self.shape_type @property def AutoShapeType(self) -> int: + """ + Indicates that an AutoShape type is unavailable for this API. + + Raises: + RuntimeError: Always raised with the message "AutoShapeType unavailable". + """ raise RuntimeError("AutoShapeType unavailable") @property def HasSmartArt(self) -> bool: + """ + Indicates whether the API represents a SmartArt shape. + + Returns: + bool: `True` if the shape is a SmartArt shape, `False` otherwise. + """ return True @property def SmartArt(self) -> object: + """ + Provide a generic placeholder object representing SmartArt details. + + Returns: + smartart (object): A generic placeholder object for SmartArt; its structure is not specified and should not be relied upon. + """ return object() @@ -189,4 +219,4 @@ def test_get_shapes_with_position_light_skips_smartart() -> None: book = _DummyBook(sheets=[_DummySheet(name="Sheet1", shapes=[smartart_shape])]) result = get_shapes_with_position(book, mode="light") - assert result["Sheet1"] == [] + assert result["Sheet1"] == [] \ No newline at end of file diff --git a/tests/core/test_shapes_smartart_utils.py b/tests/core/test_shapes_smartart_utils.py index e8c49b0..1baf776 100644 --- a/tests/core/test_shapes_smartart_utils.py +++ b/tests/core/test_shapes_smartart_utils.py @@ -46,6 +46,17 @@ class _DummyApi: class _DummyApiRaises: @property def HasSmartArt(self) -> bool: # noqa: N802 + """ + Indicates whether the shape contains SmartArt. + + This stub implementation is not available and raises an error when accessed. + + Returns: + `True` if the shape contains SmartArt, `False` otherwise. + + Raises: + RuntimeError: Always raised with the message "HasSmartArt unavailable". + """ raise RuntimeError("HasSmartArt unavailable") @@ -55,6 +66,12 @@ class _DummyShape: @property def api(self) -> object: + """ + Access the underlying API object for this shape. + + Returns: + The wrapped API object exposing the shape's underlying properties and methods. + """ return self.api_obj @@ -62,6 +79,15 @@ def api(self) -> object: class _DummyShapeRaisesApi: @property def api(self) -> object: + """ + Return the underlying API object for this wrapper. + + Returns: + object: The underlying API object. + + Raises: + RuntimeError: If the API is unavailable. + """ raise RuntimeError("api unavailable") @@ -144,4 +170,4 @@ def test_collect_smartart_node_info_and_tree() -> None: def test_collect_smartart_node_info_none() -> None: - assert shapes_mod._collect_smartart_node_info(None) == [] + assert shapes_mod._collect_smartart_node_info(None) == [] \ No newline at end of file diff --git a/tests/io/test_print_area_views.py b/tests/io/test_print_area_views.py index 8e61e90..31b1b95 100644 --- a/tests/io/test_print_area_views.py +++ b/tests/io/test_print_area_views.py @@ -16,6 +16,19 @@ def _workbook_with_print_area() -> WorkbookData: + """ + Create a sample WorkbookData containing a single sheet configured for print-area tests. + + The returned WorkbookData has book_name "book.xlsx" and a single sheet "Sheet1" whose SheetData contains: + - three rows with cells and one cell-level hyperlink, + - four shapes (two rectangle shapes, one SmartArt, one Arrow) positioned to allow in/out-of-area filtering, + - two charts (one inside the print area, one outside), + - table_candidates ["A1:B2", "C1:C1"], + - a single PrintArea spanning rows 1–2 and columns 0–1. + + Returns: + WorkbookData: The constructed workbook suitable for tests of print-area view extraction. + """ shape_inside = Shape(id=1, text="inside", l=10, t=5, w=20, h=10, type="Rect") shape_outside = Shape(id=2, text="outside", l=200, t=200, w=30, h=30, type="Rect") smartart_inside = SmartArt( @@ -106,4 +119,4 @@ def test_save_print_area_views_no_print_areas_returns_empty(tmp_path: Path) -> N wb = _workbook_with_print_area() wb.sheets["Sheet1"].print_areas = [] written = save_print_area_views(wb, tmp_path, fmt="json") - assert written == {} + assert written == {} \ No newline at end of file diff --git a/tests/models/test_models_export.py b/tests/models/test_models_export.py index 9d110e5..21a1c2e 100644 --- a/tests/models/test_models_export.py +++ b/tests/models/test_models_export.py @@ -87,6 +87,11 @@ def test_sheet_to_toon_dependency() -> None: def test_workbook_iter_and_getitem() -> None: + """ + Verify Workbook supports lookup by sheet name and iteration over (name, sheet) pairs, and that missing sheet names raise KeyError. + + Asserts that retrieving a known sheet by key returns the SheetData instance, that iterating the workbook yields a single (name, sheet) pair matching the retrieved sheet, and that accessing a nonexistent sheet raises KeyError. + """ wb = _workbook() first = wb["Sheet1"] assert isinstance(first, SheetData) @@ -123,4 +128,4 @@ def test_sheet_json_includes_smartart_nodes() -> None: data = json.loads(sheet.to_json()) assert data["shapes"][0]["kind"] == "smartart" assert data["shapes"][0]["nodes"][0]["text"] == "root" - assert data["shapes"][0]["nodes"][0]["kids"][0]["text"] == "child" + assert data["shapes"][0]["nodes"][0]["kids"][0]["text"] == "child" \ No newline at end of file diff --git a/tests/models/test_models_validation.py b/tests/models/test_models_validation.py index 3bb45dd..4a68bd0 100644 --- a/tests/models/test_models_validation.py +++ b/tests/models/test_models_validation.py @@ -80,6 +80,11 @@ def test_cellrowの数値正規化() -> None: def test_arrow_only_fields_are_not_on_shape() -> None: + """ + Ensure Arrow-specific identifier fields exist on Arrow instances and are absent from Shape instances. + + Verifies that an Arrow created with `begin_id` and `end_id` preserves those integer identifiers, and that a Shape does not expose `begin_id` or `end_id` attributes. + """ arrow = Arrow( id=None, text="a", @@ -94,4 +99,4 @@ def test_arrow_only_fields_are_not_on_shape() -> None: assert arrow.begin_id == 1 assert arrow.end_id == 2 assert not hasattr(shape, "begin_id") - assert not hasattr(shape, "end_id") + assert not hasattr(shape, "end_id") \ No newline at end of file From f74185754e3640610ce092bdee33ab97fb02d977 Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 19:11:12 +0900 Subject: [PATCH 2/3] trigger CodeQL From 7955630b4ed93544b8eb4ec8331f869fe0c6e67e Mon Sep 17 00:00:00 2001 From: harumiWeb Date: Sun, 28 Dec 2025 19:15:11 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=93=9D=20Update=20docstrings=20and=20?= =?UTF-8?q?improve=20.gitignore=20for=20better=20clarity=20and=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + scripts/gen_json_schema.py | 6 ++-- src/exstruct/core/shapes.py | 44 +++++++++++------------ src/exstruct/io/__init__.py | 20 +++++------ tests/com/test_shapes_extraction.py | 6 ++-- tests/core/test_mode_output.py | 4 +-- tests/core/test_shapes_positions_dummy.py | 12 +++---- tests/core/test_shapes_smartart_utils.py | 14 ++++---- tests/io/test_print_area_views.py | 6 ++-- tests/models/test_models_export.py | 4 +-- tests/models/test_models_validation.py | 4 +-- 11 files changed, 61 insertions(+), 60 deletions(-) diff --git a/.gitignore b/.gitignore index 156eec4..fe57ac3 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ output.json .ruff-cache/ .mypy_cache/ ruff_report.txt +ruff-error.txt mypy_report.txt coverage.xml htmlcov/ \ No newline at end of file diff --git a/scripts/gen_json_schema.py b/scripts/gen_json_schema.py index 02bb836..a67bd3a 100644 --- a/scripts/gen_json_schema.py +++ b/scripts/gen_json_schema.py @@ -41,9 +41,9 @@ def _write_schema(name: str, model: type[BaseModel], output_dir: Path) -> Path: def main() -> int: """ Generate JSON Schema files for ExStruct's public Pydantic models into the repository 'schemas' directory. - + Writes one JSON Schema file per public model into the 'schemas' folder at the project root. - + Returns: exit_code (int): 0 on success. """ @@ -69,4 +69,4 @@ def main() -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/src/exstruct/core/shapes.py b/src/exstruct/core/shapes.py index e0ba3ea..88fcff4 100644 --- a/src/exstruct/core/shapes.py +++ b/src/exstruct/core/shapes.py @@ -14,11 +14,11 @@ def compute_line_angle_deg(w: float, h: float) -> float: """ Compute the clockwise angle (in degrees) in Excel coordinates where 0° points East. - + Parameters: w (float): Horizontal delta (width, positive to the right). h (float): Vertical delta (height, positive downward). - + Returns: float: Angle in degrees measured clockwise from East (e.g., 0° = East, 90° = South). """ @@ -30,12 +30,12 @@ def angle_to_compass( ) -> Literal["E", "SE", "S", "SW", "W", "NW", "N", "NE"]: """ Map an angle in degrees to one of eight compass directions. - + The angle is interpreted with 0 degrees at East and increasing values rotating counterclockwise (45 -> NE, 90 -> N). - + Parameters: angle (float): Angle in degrees. - + Returns: str: One of `"E"`, `"SE"`, `"S"`, `"SW"`, `"W"`, `"NW"`, `"N"`, or `"NE"` corresponding to the nearest 8-point compass direction. """ @@ -49,13 +49,13 @@ def coord_to_cell_by_edges( ) -> str | None: """ Estimate the Excel A1-style cell that contains a point given cumulative row and column edge coordinates. - + Parameters: row_edges (list[float]): Monotonic list of cumulative vertical edges (top-to-bottom). Consecutive entries define row spans. col_edges (list[float]): Monotonic list of cumulative horizontal edges (left-to-right). Consecutive entries define column spans. x (float): Horizontal coordinate (same coordinate system as col_edges). y (float): Vertical coordinate (same coordinate system as row_edges). - + Returns: str | None: A1-style cell address (e.g., "B3") if the point falls inside the grid; `None` if the point is outside the provided edge ranges. Intervals are treated as left-inclusive and right-exclusive: [edge_i, edge_{i+1}). """ @@ -113,15 +113,15 @@ def _should_include_shape( ) -> bool: """ Determine whether a shape should be included in the output based on its properties and the selected output mode. - + Modes: - "light": always exclude shapes. - "standard": include when the shape has text or represents a relationship (line/connector). - "verbose": include all shapes (other global exclusions are handled elsewhere). - + Parameters: output_mode (str): One of "light", "standard", or "verbose"; controls inclusion rules. - + Returns: bool: `True` if the shape should be emitted, `False` otherwise. """ @@ -182,7 +182,7 @@ class _SmartArtLike(Protocol): def _shape_has_smartart(shp: xw.Shape) -> bool: """ Determine whether a shape exposes SmartArt content. - + Returns: bool: `True` if the shape exposes SmartArt (i.e., has an accessible `HasSmartArt` attribute), `False` otherwise. """ @@ -199,7 +199,7 @@ def _shape_has_smartart(shp: xw.Shape) -> bool: def _get_smartart_layout_name(smartart: _SmartArtLike | None) -> str: """ Get the SmartArt layout name or "Unknown" if it cannot be determined. - + Returns: layout_name (str): The layout name from `smartart.Layout.Name`, or "Unknown" when `smartart` is None or the name cannot be retrieved. """ @@ -218,10 +218,10 @@ def _collect_smartart_node_info( ) -> list[tuple[int, str]]: """ Extract a list of (level, text) tuples for each node present in the given SmartArt. - + Parameters: smartart (_SmartArtLike | None): A SmartArt-like COM object or `None`. If `None` or inaccessible, no nodes are collected. - + Returns: list[tuple[int, str]]: A list of tuples where each tuple is (node level, node text). Returns an empty list if the SmartArt is `None`, inaccessible, or if nodes lack a numeric level. """ @@ -252,7 +252,7 @@ def _collect_smartart_node_info( def _get_smartart_node_level(node: _SmartArtNodeLike) -> int | None: """ Get the numerical level of a SmartArt node. - + Returns: int | None: The node's level as an integer, or `None` if the level is missing or cannot be converted to an integer. """ @@ -265,11 +265,11 @@ def _get_smartart_node_level(node: _SmartArtNodeLike) -> int | None: def _build_smartart_tree(nodes_info: list[tuple[int, str]]) -> list[SmartArtNode]: """ Build a nested tree of SmartArtNode objects from a flat list of (level, text) tuples. - + Parameters: nodes_info (list[tuple[int, str]]): Ordered tuples where each tuple is (level, text); `level` is the hierarchical depth (integer) and `text` is the node label. - + Returns: roots (list[SmartArtNode]): Top-level SmartArtNode instances whose `kids` lists contain their nested child nodes according to the provided levels. @@ -291,10 +291,10 @@ def _build_smartart_tree(nodes_info: list[tuple[int, str]]) -> list[SmartArtNode def _extract_smartart_nodes(smartart: _SmartArtLike | None) -> list[SmartArtNode]: """ Convert a SmartArt COM object into a list of root SmartArtNode trees. - + Parameters: smartart (_SmartArtLike | None): SmartArt-like COM object to extract nodes from; pass `None` to produce an empty list. - + Returns: list[SmartArtNode]: Root nodes representing the hierarchical SmartArt structure (each node contains its text and children). """ @@ -307,11 +307,11 @@ def get_shapes_with_position( # noqa: C901 ) -> dict[str, list[Shape | Arrow | SmartArt]]: """ Scan all shapes in each worksheet and collect their positional and metadata information. - + Parameters: workbook (Book): The xlwings workbook to scan. mode (str): Output detail level; "light" skips most shapes, "standard" includes shapes with text or relationships, and "verbose" includes full size/rotation details. - + Returns: dict[str, list[Shape | Arrow | SmartArt]]: Mapping of sheet name to a list of collected shape objects (Shape, Arrow, or SmartArt) containing position (left/top), optional size (width/height), textual content, and other captured metadata (ids, directions, connections, layout/nodes for SmartArt). """ @@ -518,4 +518,4 @@ def get_shapes_with_position( # noqa: C901 if end_name: shape_obj.end_id = name_to_id.get(end_name) shape_data[sheet.name] = shapes - return shape_data \ No newline at end of file + return shape_data diff --git a/src/exstruct/io/__init__.py b/src/exstruct/io/__init__.py index 1ea419c..4e7c83d 100644 --- a/src/exstruct/io/__init__.py +++ b/src/exstruct/io/__init__.py @@ -32,12 +32,12 @@ def dict_without_empty_values(obj: object) -> JsonStructure: """ Remove None, empty string, empty list, and empty dict values from a nested structure or supported model object. - + Recursively processes dicts, lists, and supported model types (WorkbookData, CellRow, Chart, PrintArea, PrintAreaView, Shape, Arrow, SmartArt). Model instances are converted to dictionaries with None fields excluded before recursive cleaning. Values considered empty and removed are: `None`, `""` (empty string), `[]` (empty list), and `{}` (empty dict). - + Parameters: obj (object): A value to clean; may be a dict, list, scalar, or one of the supported model instances. - + Returns: JsonStructure: The input structure with empty values removed, preserving other values and nesting. """ @@ -185,14 +185,14 @@ def _area_to_px_rect( def _rects_overlap(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> bool: """ Determine whether two axis-aligned rectangles intersect (overlap in area). - + Parameters: a (tuple[int, int, int, int]): Rectangle A as (left, top, right, bottom). b (tuple[int, int, int, int]): Rectangle B as (left, top, right, bottom). - + Notes: Rectangles are treated as half-open in this context: if they only touch at edges or corners, they do not count as overlapping. - + Returns: bool: `True` if the rectangles have a non-zero-area intersection, `False` otherwise. """ @@ -204,13 +204,13 @@ def _filter_shapes_to_area( ) -> list[Shape | Arrow | SmartArt]: """ Filter drawable shapes to those that intersect the given print area. - + Shapes and the print area are compared in approximate pixel coordinates. Shapes that have both width and height are included when their bounding rectangle overlaps the area. Shapes with unknown size (width or height is None) are treated as a point at their left/top coordinates and included only if that point lies inside the area. - + Parameters: shapes (list[Shape | Arrow | SmartArt]): Drawable objects with `l`, `t`, `w`, `h` coordinates. area (PrintArea): Cell-based print area that will be converted to an approximate pixel rectangle. - + Returns: list[Shape | Arrow | SmartArt]: Subset of `shapes` whose geometry intersects the print area. """ @@ -544,4 +544,4 @@ def save_sheets( "serialize_workbook", "_require_yaml", "_require_toon", -] \ No newline at end of file +] diff --git a/tests/com/test_shapes_extraction.py b/tests/com/test_shapes_extraction.py index c7127d1..a9b7a98 100644 --- a/tests/com/test_shapes_extraction.py +++ b/tests/com/test_shapes_extraction.py @@ -66,7 +66,7 @@ def _make_workbook_with_shapes(path: Path) -> None: def test_図形の種別とテキストが抽出される(tmp_path: Path) -> None: """ Verifies extraction of shape types, texts, IDs, and uniqueness from a workbook containing various shapes. - + Creates a workbook with a rectangle, an oval, a line, a nested group, and a connector, then extracts shapes from "Sheet1" and asserts: - a shape with text "rect" is an AutoShape, has non-negative left/top coordinates, and a positive id; - a nested child with text "inner" is not reported as a Group and has a positive id; @@ -103,7 +103,7 @@ def test_図形の種別とテキストが抽出される(tmp_path: Path) -> Non def test_線図形の方向と矢印情報が抽出される(tmp_path: Path) -> None: """ Verifies that a line shape's direction and arrow style information is extracted correctly from a workbook. - + Creates a workbook containing shapes, extracts shapes from "Sheet1", finds an Arrow with a begin or end arrow style, and asserts its direction is "E". """ _ensure_excel() @@ -147,4 +147,4 @@ def test_コネクターの接続元と接続先が抽出される(tmp_path: Pat # Connected shape ids should correspond to some emitted shapes' id. shape_ids = {s.id for s in shapes if s.id is not None} assert conn.begin_id in shape_ids - assert conn.end_id in shape_ids \ No newline at end of file + assert conn.end_id in shape_ids diff --git a/tests/core/test_mode_output.py b/tests/core/test_mode_output.py index f014d71..f90bb5d 100644 --- a/tests/core/test_mode_output.py +++ b/tests/core/test_mode_output.py @@ -16,7 +16,7 @@ def _make_basic_book(path: Path) -> None: """ Create and save a simple Excel workbook with one sheet named "Sheet1" containing "v1" in A1 and "v2" in B1. - + Parameters: path (Path): Filesystem path where the workbook will be saved. """ @@ -187,4 +187,4 @@ def test_CLI_defaults_to_stdout(tmp_path: Path) -> None: ] result = subprocess.run(cmd, capture_output=True, text=True) assert result.returncode == 0 - assert '"book_name": "book.xlsx"' in result.stdout \ No newline at end of file + assert '"book_name": "book.xlsx"' in result.stdout diff --git a/tests/core/test_shapes_positions_dummy.py b/tests/core/test_shapes_positions_dummy.py index ecbaa6b..8a8defb 100644 --- a/tests/core/test_shapes_positions_dummy.py +++ b/tests/core/test_shapes_positions_dummy.py @@ -45,7 +45,7 @@ def Line(self) -> _DummyLine: def Rotation(self) -> float: """ Get the shape's rotation angle. - + Returns: rotation (float): Rotation angle in degrees. """ @@ -60,7 +60,7 @@ class _DummyApiSmartArt: def Type(self) -> int: """ Get the shape's type identifier. - + Returns: shape_type (int): Integer identifier for the shape type. """ @@ -70,7 +70,7 @@ def Type(self) -> int: def AutoShapeType(self) -> int: """ Indicates that an AutoShape type is unavailable for this API. - + Raises: RuntimeError: Always raised with the message "AutoShapeType unavailable". """ @@ -80,7 +80,7 @@ def AutoShapeType(self) -> int: def HasSmartArt(self) -> bool: """ Indicates whether the API represents a SmartArt shape. - + Returns: bool: `True` if the shape is a SmartArt shape, `False` otherwise. """ @@ -90,7 +90,7 @@ def HasSmartArt(self) -> bool: def SmartArt(self) -> object: """ Provide a generic placeholder object representing SmartArt details. - + Returns: smartart (object): A generic placeholder object for SmartArt; its structure is not specified and should not be relied upon. """ @@ -219,4 +219,4 @@ def test_get_shapes_with_position_light_skips_smartart() -> None: book = _DummyBook(sheets=[_DummySheet(name="Sheet1", shapes=[smartart_shape])]) result = get_shapes_with_position(book, mode="light") - assert result["Sheet1"] == [] \ No newline at end of file + assert result["Sheet1"] == [] diff --git a/tests/core/test_shapes_smartart_utils.py b/tests/core/test_shapes_smartart_utils.py index 1baf776..2168013 100644 --- a/tests/core/test_shapes_smartart_utils.py +++ b/tests/core/test_shapes_smartart_utils.py @@ -48,12 +48,12 @@ class _DummyApiRaises: def HasSmartArt(self) -> bool: # noqa: N802 """ Indicates whether the shape contains SmartArt. - + This stub implementation is not available and raises an error when accessed. - + Returns: `True` if the shape contains SmartArt, `False` otherwise. - + Raises: RuntimeError: Always raised with the message "HasSmartArt unavailable". """ @@ -68,7 +68,7 @@ class _DummyShape: def api(self) -> object: """ Access the underlying API object for this shape. - + Returns: The wrapped API object exposing the shape's underlying properties and methods. """ @@ -81,10 +81,10 @@ class _DummyShapeRaisesApi: def api(self) -> object: """ Return the underlying API object for this wrapper. - + Returns: object: The underlying API object. - + Raises: RuntimeError: If the API is unavailable. """ @@ -170,4 +170,4 @@ def test_collect_smartart_node_info_and_tree() -> None: def test_collect_smartart_node_info_none() -> None: - assert shapes_mod._collect_smartart_node_info(None) == [] \ No newline at end of file + assert shapes_mod._collect_smartart_node_info(None) == [] diff --git a/tests/io/test_print_area_views.py b/tests/io/test_print_area_views.py index 31b1b95..f8b0914 100644 --- a/tests/io/test_print_area_views.py +++ b/tests/io/test_print_area_views.py @@ -18,14 +18,14 @@ def _workbook_with_print_area() -> WorkbookData: """ Create a sample WorkbookData containing a single sheet configured for print-area tests. - + The returned WorkbookData has book_name "book.xlsx" and a single sheet "Sheet1" whose SheetData contains: - three rows with cells and one cell-level hyperlink, - four shapes (two rectangle shapes, one SmartArt, one Arrow) positioned to allow in/out-of-area filtering, - two charts (one inside the print area, one outside), - table_candidates ["A1:B2", "C1:C1"], - a single PrintArea spanning rows 1–2 and columns 0–1. - + Returns: WorkbookData: The constructed workbook suitable for tests of print-area view extraction. """ @@ -119,4 +119,4 @@ def test_save_print_area_views_no_print_areas_returns_empty(tmp_path: Path) -> N wb = _workbook_with_print_area() wb.sheets["Sheet1"].print_areas = [] written = save_print_area_views(wb, tmp_path, fmt="json") - assert written == {} \ No newline at end of file + assert written == {} diff --git a/tests/models/test_models_export.py b/tests/models/test_models_export.py index 21a1c2e..4d96008 100644 --- a/tests/models/test_models_export.py +++ b/tests/models/test_models_export.py @@ -89,7 +89,7 @@ def test_sheet_to_toon_dependency() -> None: def test_workbook_iter_and_getitem() -> None: """ Verify Workbook supports lookup by sheet name and iteration over (name, sheet) pairs, and that missing sheet names raise KeyError. - + Asserts that retrieving a known sheet by key returns the SheetData instance, that iterating the workbook yields a single (name, sheet) pair matching the retrieved sheet, and that accessing a nonexistent sheet raises KeyError. """ wb = _workbook() @@ -128,4 +128,4 @@ def test_sheet_json_includes_smartart_nodes() -> None: data = json.loads(sheet.to_json()) assert data["shapes"][0]["kind"] == "smartart" assert data["shapes"][0]["nodes"][0]["text"] == "root" - assert data["shapes"][0]["nodes"][0]["kids"][0]["text"] == "child" \ No newline at end of file + assert data["shapes"][0]["nodes"][0]["kids"][0]["text"] == "child" diff --git a/tests/models/test_models_validation.py b/tests/models/test_models_validation.py index 4a68bd0..a587390 100644 --- a/tests/models/test_models_validation.py +++ b/tests/models/test_models_validation.py @@ -82,7 +82,7 @@ def test_cellrowの数値正規化() -> None: def test_arrow_only_fields_are_not_on_shape() -> None: """ Ensure Arrow-specific identifier fields exist on Arrow instances and are absent from Shape instances. - + Verifies that an Arrow created with `begin_id` and `end_id` preserves those integer identifiers, and that a Shape does not expose `begin_id` or `end_id` attributes. """ arrow = Arrow( @@ -99,4 +99,4 @@ def test_arrow_only_fields_are_not_on_shape() -> None: assert arrow.begin_id == 1 assert arrow.end_id == 2 assert not hasattr(shape, "begin_id") - assert not hasattr(shape, "end_id") \ No newline at end of file + assert not hasattr(shape, "end_id")