Repository: marimo-team/marimo

Issue:
Marimo can't export classes implementing property methods
### Describe the bug

Implementing python's full property mechanism results in classes that cannot be exported.

### Will you submit a PR?

- [ ] Yes

### Environment

<details>

```
Replace this line with the output of marimo env. Leave the backticks in place.
```

</details>


### Code to reproduce

![Image](https://github.com/user-attachments/assets/ba425676-7247-4a47-a953-4591e8c8821c)

Discussion:
@robdmc: FWIW, this is my current work-around.

![Image](https://github.com/user-attachments/assets/6cc09861-5e01-4deb-bcc1-223e1fb7f7ce)@mscolnick: Nice find - we can definitely fix this

Solution patch:
diff --git a/frontend/src/plugins/impl/DatePickerPlugin.tsx b/frontend/src/plugins/impl/DatePickerPlugin.tsx
index 622967eb..d19e6d05 100644
--- a/frontend/src/plugins/impl/DatePickerPlugin.tsx
+++ b/frontend/src/plugins/impl/DatePickerPlugin.tsx
@@ -14,6 +14,7 @@ interface Data {
   stop: string;
   step?: string;
   fullWidth: boolean;
+  disabled?: boolean;
 }
 
 export class DatePickerPlugin implements IPlugin<T, Data> {
@@ -26,6 +27,7 @@ export class DatePickerPlugin implements IPlugin<T, Data> {
     stop: z.string(),
     step: z.string().optional(),
     fullWidth: z.boolean().default(false),
+    disabled: z.boolean().optional(),
   });
 
   render(props: IPluginProps<T, Data>): JSX.Element {
@@ -34,6 +36,7 @@ export class DatePickerPlugin implements IPlugin<T, Data> {
         {...props.data}
         value={props.value}
         setValue={props.setValue}
+        disabled={props.data.disabled}
       />
     );
   }
@@ -63,6 +66,7 @@ const DatePickerComponent = (props: DatePickerProps): JSX.Element => {
         aria-label={props.label ?? "date picker"}
         minValue={parseDate(props.start)}
         maxValue={parseDate(props.stop)}
+        isDisabled={props.disabled}
       />
     </Labeled>
   );
diff --git a/marimo/_ast/visitor.py b/marimo/_ast/visitor.py
index 7c9eb095..5c5a075f 100644
--- a/marimo/_ast/visitor.py
+++ b/marimo/_ast/visitor.py
@@ -471,11 +471,25 @@ class ScopedVisitor(ast.NodeVisitor):
             # The unbounded defs are carried from the recursive visitor
             # excluding all references found in exclusively in scope.
             ignore_refs = set()
+            class_def: set[Name] = set()
             for var in mock_visitor.variable_data:
                 for data in mock_visitor.variable_data[var]:
                     if data.kind in ("function", "class"):
-                        unbounded_refs |= data.unbounded_refs
+                        unbounded_refs |= data.unbounded_refs - class_def
                         ignore_refs |= data.required_refs
+                        # class def is captured because the following is valid:
+                        # >>> class C:
+                        # >>>     A = 1
+                        # >>>     B = A + 1
+                        # >>>     @property
+                        # >>>     def prop(self): ...
+                        # >>>     @prop.setter
+                        # >>>     def prop(self, value): ...
+                        #
+                        # - even though these variables only live in scope of
+                        # the class definition.
+                        # Thus, if it has been declared, it's not "unbounded"
+                        class_def.add(var)
                     else:
                         unbounded_refs |= data.required_refs
                 unbounded_refs |= mock_visitor.refs - ignore_refs
diff --git a/marimo/_plugins/ui/_impl/dates.py b/marimo/_plugins/ui/_impl/dates.py
index cfc5ee5a..35ffbbbb 100644
--- a/marimo/_plugins/ui/_impl/dates.py
+++ b/marimo/_plugins/ui/_impl/dates.py
@@ -70,6 +70,7 @@ class date(UIElement[str, dt.date]):
         label (str, optional): Markdown label for the element.
         on_change (Callable[[datetime.date], None], optional): Optional callback to run when this element's value changes.
         full_width (bool, optional): Whether the input should take up the full width of its container.
+        disabled (bool, optional): Whether the input should be disabled.
     """
 
     _name: Final[str] = "marimo-date"
@@ -85,6 +86,7 @@ class date(UIElement[str, dt.date]):
         label: str = "",
         on_change: Optional[Callable[[dt.date], None]] = None,
         full_width: bool = False,
+        disabled: bool = False,
     ) -> None:
         if isinstance(start, str):
             start = self._convert_value(start)
@@ -125,6 +127,7 @@ class date(UIElement[str, dt.date]):
                 "start": self._start.isoformat(),
                 "stop": self._stop.isoformat(),
                 "full-width": full_width,
+                "disabled": disabled,
             },
             on_change=on_change,
         )


Test patch:
diff --git a/tests/_ast/test_toplevel.py b/tests/_ast/test_toplevel.py
index 9aef6151..b3e5e677 100644
--- a/tests/_ast/test_toplevel.py
+++ b/tests/_ast/test_toplevel.py
@@ -348,6 +348,25 @@ class TestTopLevelExtraction:
             s.type for s in extraction
         ], [s.hint for s in extraction]
 
+    @staticmethod
+    def test_name_is_not_propagated(app) -> None:
+        @app.cell
+        def cell():
+            value = 1
+
+        @app.function
+        def to_be_demoted():
+            return value + 1  # type: ignore # noqa: F821
+
+        extraction = TopLevelExtraction.from_app(InternalApp(app))
+        assert [
+            TopLevelType.CELL,
+            TopLevelType.CELL,
+        ] == [s.type for s in extraction], [s.hint for s in extraction]
+        assert ["cell", "_"] == [s.name for s in extraction], [
+            s.hint for s in extraction
+        ]
+
 
 class TestTopLevelClasses:
     @staticmethod
@@ -455,9 +474,16 @@ class TestTopLevelClasses:
         ] == [s.type for s in extraction], [s.hint for s in extraction]
 
     @staticmethod
-    def test_class_invocation(app) -> None:
+    def test_class_properties(app) -> None:
         @app.class_definition
-        class Example: ...
+        class Example:
+            @property
+            def prop(self) -> int:
+                return 1
+
+            @prop.setter
+            def prop(self, value: int) -> None:
+                pass
 
         @app.function
         def f():
@@ -470,23 +496,19 @@ class TestTopLevelClasses:
         ] == [s.type for s in extraction], [s.hint for s in extraction]
 
     @staticmethod
-    def test_name_is_not_propagated(app) -> None:
-        @app.cell
-        def cell():
-            value = 1
+    def test_class_invocation(app) -> None:
+        @app.class_definition
+        class Example: ...
 
         @app.function
-        def to_be_demoted():
-            return value + 1  # type: ignore # noqa: F821
+        def f():
+            return Example()
 
         extraction = TopLevelExtraction.from_app(InternalApp(app))
         assert [
-            TopLevelType.CELL,
-            TopLevelType.CELL,
+            TopLevelType.TOPLEVEL,
+            TopLevelType.TOPLEVEL,
         ] == [s.type for s in extraction], [s.hint for s in extraction]
-        assert ["cell", "_"] == [s.name for s in extraction], [
-            s.hint for s in extraction
-        ]
 
 
 class TestTopLevelHook: