Repository: wandb/wandb
Pull request: fix(sdk): fix behavior of Run in disabled mode
Description
-----------
<!--
Include reference to internal ticket "Fixes WB-NNNNN" and/or GitHub issue "Fixes #NNNN" (if applicable)
-->

Fixes: #8112

Ensure that all public attributes and methods of the Run object are available in disabled mode.

<!--
NEW: We're using a new changelog format that's more useful for users. Please
see CHANGELOG.md for details and update on relevant changes such as feature
additions, bug fixes, or removals/deprecations.
-->
- [x] I updated CHANGELOG.md, or it's not applicable


Testing
-------
How was this PR tested?

<!--
Ensure PR title compliance with the [conventional commits standards](https://github.com/wandb/wandb/blob/main/CONTRIBUTING.md#conventional-commits)
-->


Problem statement:
[CLI]: initializing twice leads to `AttributeError: 'Run' object has no attribute '_telemetry_obj'.`
### Describe the bug

<!--- Description of the issue below  -->
## Description

Starting from `wandb==0.17.6`, I noticed an issue that if I initialize twice as `run = wandb.init(mode="disabled")` I get `AttributeError: 'Run' object has no attribute '_telemetry_obj'`.

Ordinary mode, i.e., `run = wandb.init()` can be called many times.

Up until 0.17.5, `run = wandb.init(mode="disabled")` returned an instance of `RunDisabled`. This seems to have changed from 0.17.6.

<!--- A minimal code snippet between the quotes below  -->
## Steps to reproduce the issue
```python
import wandb
run = wandb.init(mode="disabled")
run = wandb.init(mode="disabled")
```

<!--- A full traceback of the exception in the quotes below -->
## Traceback
```shell
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/ryoto/conda/envs/materials/lib/python3.10/site-packages/wandb/sdk/wandb_init.py", line 1191, in init
    wandb._sentry.reraise(e)
  File "/home/ryoto/conda/envs/materials/lib/python3.10/site-packages/wandb/analytics/sentry.py", line 155, in reraise
    raise exc.with_traceback(sys.exc_info()[2])
  File "/home/ryoto/conda/envs/materials/lib/python3.10/site-packages/wandb/sdk/wandb_init.py", line 1176, in init
    wi.setup(kwargs)
  File "/home/ryoto/conda/envs/materials/lib/python3.10/site-packages/wandb/sdk/wandb_init.py", line 211, in setup
    with telemetry.context(obj=self._init_telemetry_obj) as tel:
  File "/home/ryoto/conda/envs/materials/lib/python3.10/site-packages/wandb/sdk/lib/telemetry.py", line 43, in __exit__
    self._run._telemetry_callback(self._obj)
  File "/home/ryoto/conda/envs/materials/lib/python3.10/site-packages/wandb/sdk/wandb_run.py", line 799, in _telemetry_callback
    self._telemetry_obj.MergeFrom(telem_obj)
AttributeError: 'Run' object has no attribute '_telemetry_obj'. Did you mean: '_telemetry_flush'?
```


### Additional Files

_No response_

### Environment

WandB version: 0.17.6
OS: Ubuntu 22.04

Python version: 3.10

Versions of relevant libraries:


### Additional Context

_No response_

Hints from issue discussion:

Commit message: clean up

Patch to review:
diff --git a/.vscode/settings.json b/.vscode/settings.json
index d7fe64fa888..88f78d3c07a 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -12,22 +12,11 @@
     "./wandb/vendor/gql-0.2.0",
     "./wandb/vendor/graphql-core-1.1"
   ],
-  "python.linting.enabled": true,
-  "python.linting.flake8Enabled": false,
-  "python.linting.mypyEnabled": false,
   "python.testing.unittestEnabled": false,
   "python.testing.pytestEnabled": true,
-  "python.testing.nosetestsEnabled": false,
-  "python.formatting.provider": "black",
-  "python.formatting.autopep8Args": [
-    "--max-line-length",
-    "120"
-  ],
   "python.testing.pytestArgs": [
     "."
   ],
-  "python.linting.pylintEnabled": false,
-  "jupyter.jupyterServerType": "local",
   "[python]": {
     "editor.defaultFormatter": "charliermarsh.ruff",
     "editor.formatOnSave": true
diff --git a/tests/pytest_tests/unit_tests/test_mode_disabled.py b/tests/pytest_tests/unit_tests/test_mode_disabled.py
new file mode 100644
index 00000000000..683bce498de
--- /dev/null
+++ b/tests/pytest_tests/unit_tests/test_mode_disabled.py
@@ -0,0 +1,37 @@
+import pickle
+import tempfile
+
+import pytest
+import wandb
+
+
+def test_mode_disabled():
+    """Test that the user can access all attributes of a Run object in disabled mode."""
+    run = wandb.init(mode="disabled")
+    symbols = [s for s in dir(run) if not s.startswith("_") and s != "log"]
+
+    # try logging some stuff
+    run.log({"a": 1})
+
+    for symbol in symbols:
+        # try accessing the attribute
+        getattr(run, symbol)
+        if callable(getattr(run, symbol)):
+            # try calling the method
+            getattr(run, symbol)()
+
+    # disabled mode should not allow referencing attributes that don't exist
+    with pytest.raises(AttributeError):
+        assert run.here_s_a_twist_i_dont_exist
+
+    with pytest.raises(AttributeError):
+        assert run.me_too()
+
+
+def test_disabled_can_pickle():
+    # This case comes up when using wandb in disabled mode, with keras
+    # https://wandb.atlassian.net/browse/WB-3981
+    run = wandb.init(mode="disabled")
+
+    with tempfile.NamedTemporaryFile() as temp_file:
+        pickle.dump(run, temp_file)
diff --git a/wandb/sdk/wandb_init.py b/wandb/sdk/wandb_init.py
index edf86739c9a..33be3c21788 100644
--- a/wandb/sdk/wandb_init.py
+++ b/wandb/sdk/wandb_init.py
@@ -15,6 +15,7 @@
 import platform
 import sys
 import tempfile
+import time
 from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, Union
 
 import wandb
@@ -174,7 +175,9 @@ def setup(self, kwargs: Any) -> None:  # noqa: C901
         # we add this logic to be backward compatible with the old behavior of disable
         # where it would disable the service if the mode was set to disabled
         mode = kwargs.get("mode")
-        settings_mode = (kwargs.get("settings") or {}).get("mode")
+        settings_mode = (kwargs.get("settings") or {}).get("mode") or os.environ.get(
+            "WANDB_MODE"
+        )
         _disable_service = mode == "disabled" or settings_mode == "disabled"
         setup_settings = {"_disable_service": _disable_service}
 
@@ -522,17 +525,60 @@ def _log_setup(self, settings: Settings) -> None:
         logger.info(f"Logging internal logs to {settings.log_internal}")
 
     def _make_run_disabled(self) -> Run:
+        """Create a disabled run object.
+
+        This enables the user to call wandb.init(mode="disabled") and still have a Run object.
+        We ensure that the attributes and methods of the run object are available, but do nothing.
+        """
         drun = Run(settings=Settings(mode="disabled", files_dir=tempfile.gettempdir()))
+        # config and summary objects
         drun._config = wandb.wandb_sdk.wandb_config.Config()
         drun._config.update(self.sweep_config)
         drun._config.update(self.config)
         drun.summary = SummaryDisabled()  # type: ignore
+        # methods
         drun.log = lambda data, *_, **__: drun.summary.update(data)  # type: ignore
         drun.finish = lambda *_, **__: module.unset_globals()  # type: ignore
+        drun.join = drun.finish  # type: ignore
+        drun.define_metric = lambda *_, **__: wandb.wandb_sdk.wandb_metric.Metric(
+            "dummy"
+        )  # type: ignore
+        drun.save = lambda *_, **__: False  # type: ignore
+        for symbol in (
+            "alert",
+            "finish_artifact",
+            "get_project_url",
+            "get_sweep_url",
+            "get_url",
+            "link_artifact",
+            "link_model",
+            "use_artifact",
+            "log_artifact",
+            "log_code",
+            "log_model",
+            "use_model",
+            "mark_preempting",
+            "plot_table",
+            "restore",
+            "status",
+            "watch",
+            "unwatch",
+            "upsert_artifact",
+        ):
+            setattr(drun, symbol, lambda *_, **__: None)  # type: ignore
+        # attributes
         drun._step = 0
+        drun._attach_id = None
         drun._run_obj = None
         drun._run_id = runid.generate_id()
         drun._name = "dummy-" + drun.id
+        drun._project = "dummy"
+        drun._entity = "dummy"
+        drun._tags = tuple()
+        drun._notes = None
+        drun._group = None
+        drun._start_time = time.time()
+        drun._starting_step = 0
         module.set_global(
             run=drun,
             config=drun.config,
diff --git a/wandb/sdk/wandb_run.py b/wandb/sdk/wandb_run.py
index e36c30870f2..8debbc6d5c4 100644
--- a/wandb/sdk/wandb_run.py
+++ b/wandb/sdk/wandb_run.py
@@ -1089,13 +1089,14 @@ def project_name(self) -> str:
     @_run_decorator._attach
     def mode(self) -> str:
         """For compatibility with `0.9.x` and earlier, deprecate eventually."""
-        deprecate.deprecate(
-            field_name=deprecate.Deprecated.run__mode,
-            warning_message=(
-                "The mode property of wandb.run is deprecated "
-                "and will be removed in a future release."
-            ),
-        )
+        if hasattr(self, "_telemetry_obj"):
+            deprecate.deprecate(
+                field_name=deprecate.Deprecated.run__mode,
+                warning_message=(
+                    "The mode property of wandb.run is deprecated "
+                    "and will be removed in a future release."
+                ),
+            )
         return "dryrun" if self._settings._offline else "run"
 
     @property
@@ -2158,12 +2159,13 @@ def _finish(
     @_run_decorator._attach
     def join(self, exit_code: Optional[int] = None) -> None:
         """Deprecated alias for `finish()` - use finish instead."""
-        deprecate.deprecate(
-            field_name=deprecate.Deprecated.run__join,
-            warning_message=(
-                "wandb.run.join() is deprecated, please use wandb.run.finish()."
-            ),
-        )
+        if hasattr(self, "_telemetry_obj"):
+            deprecate.deprecate(
+                field_name=deprecate.Deprecated.run__join,
+                warning_message=(
+                    "wandb.run.join() is deprecated, please use wandb.run.finish()."
+                ),
+            )
         self._finish(exit_code=exit_code)
 
     @_run_decorator._noop_on_finish()
@@ -2368,11 +2370,7 @@ def _atexit_cleanup(self, exit_code: Optional[int] = None) -> None:
             return
         self._atexit_cleanup_called = True
 
-        exit_code = (
-            exit_code  #
-            or (self._hooks and self._hooks.exit_code)
-            or 0
-        )
+        exit_code = exit_code or (self._hooks and self._hooks.exit_code) or 0
         self._exit_code = exit_code
         logger.info(f"got exitcode: {exit_code}")
 


Code review comments:
diff_hunk: @@ -522,17 +525,60 @@ def _log_setup(self, settings: Settings) -> None:
         logger.info(f"Logging internal logs to {settings.log_internal}")
 
     def _make_run_disabled(self) -> Run:
+        """Create a disabled run object.
path: wandb/sdk/wandb_init.py
text: @user1:
```suggestion
        """Returns a Run-like object where all methods are no-ops.
```