Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
9a9f76e
SG-43217 Integrate Flow Data SDK as a vendored beta library
stevelittlefish May 13, 2026
13366d6
SG-43217 Drop _patch_flow_data_sdk_version now that upstream is fixed
stevelittlefish May 14, 2026
176b338
SG-43217 Drop _load_packages_from_zip required flag
stevelittlefish May 14, 2026
b907871
Removed unused import
stevelittlefish May 14, 2026
f96c7d6
SG-43217 Put shared zips ahead of pkgs.zip on sys.path
stevelittlefish May 15, 2026
8c006ca
SG-43217 Release importlib.metadata file handles after loading vendor…
stevelittlefish May 15, 2026
b6388cf
SG-43217 Gate importlib.metadata handle release to Windows
stevelittlefish May 15, 2026
5aca09a
SG-43217 Address Copilot review feedback
stevelittlefish May 15, 2026
f41a952
SG-43217 Address review feedback
stevelittlefish May 19, 2026
37d75bc
SG-43166 Integrate MEDM authentication into Toolkit bootstrap
stevelittlefish May 20, 2026
27d1e01
SG-43217 Address Julien's review feedback
stevelittlefish May 20, 2026
8381203
Put the comments back that Claude decided were not worthy of existing
stevelittlefish May 20, 2026
01b05b9
Merge remote-tracking branch 'origin/ticket/SG-43217/flow-data-sdk' i…
stevelittlefish May 21, 2026
9f63cfe
SG-43166 Confirm sg_flow_am_id field name
stevelittlefish May 21, 2026
cffb0e7
SG-43166 Apply Black formatting
stevelittlefish May 21, 2026
7fcd1f8
SG-43166 Use production APS base URL
stevelittlefish May 21, 2026
dac96ba
SG-43166 Address Julien's review feedback
stevelittlefish May 22, 2026
7a07f9b
Make the linter happy
stevelittlefish May 22, 2026
872d88f
Rebuild pkgs.zip for all Python versions and fix namespace package as…
stevelittlefish May 22, 2026
779a57a
Reworked flow auth to store the auth token in a file
stevelittlefish May 26, 2026
648a105
Updated vendor packages
stevelittlefish May 26, 2026
1bb1200
Linter
stevelittlefish May 26, 2026
44319fe
Set correct app id and auth url
stevelittlefish May 27, 2026
d24b615
SDK entrypoint (get_flow_client())
stevelittlefish May 27, 2026
c6c84f3
Made the linter happy
stevelittlefish May 27, 2026
0dc9568
Fixed flow data import
stevelittlefish May 27, 2026
af6b3f5
One more stray import
stevelittlefish May 27, 2026
9b834e3
CR feedback - empty line in imports
stevelittlefish May 27, 2026
e601157
Sorted imports alphabetically
stevelittlefish May 27, 2026
176e8ac
Potential fix for pull request finding
stevelittlefish Jun 2, 2026
a434383
Restrict OAuth callback server to loopback addresses
stevelittlefish Jun 2, 2026
63878d8
Shut down OAuth callback server after auth code is received
stevelittlefish Jun 2, 2026
f66326e
Changed description from test value
stevelittlefish Jun 2, 2026
d64191f
Ran adsk_auth package through isort
stevelittlefish Jun 3, 2026
091db05
add flow am id to project context while authenticating
yungsiow Jun 3, 2026
c40bc53
Update python/tank_vendor/adsk_auth/pkce.py
stevelittlefish Jun 4, 2026
790cc28
Revert "add flow am id to project context while authenticating"
stevelittlefish Jun 4, 2026
009cca4
SG-43167 Add flow_am_project_id to Toolkit Context
stevelittlefish Jun 4, 2026
c1ff6b2
Avoided unnecessary query of flow am project id
stevelittlefish Jun 4, 2026
7ec58ba
SG-43167 Fix entity=None guard and update flow auth tests
stevelittlefish Jun 4, 2026
d106fba
SG-43167 Fix bootstrap AM check to use sg_connection directly
stevelittlefish Jun 4, 2026
85b0652
adjustments to storing flow project id in context
yungsiow Jun 4, 2026
214c8e9
SG-43167 Fix broken tests after context flow_am_project_id refactor
stevelittlefish Jun 5, 2026
de92967
Cleaned up imports
stevelittlefish Jun 5, 2026
e150a3a
Merge branch 'master' into ticket/SG-43167/flow-am-in-context
stevelittlefish Jun 5, 2026
3ad0d5d
Fix botched merge conflict resolution in manager.py
stevelittlefish Jun 5, 2026
0210b60
Cleaned up another lazy import
stevelittlefish Jun 5, 2026
8482103
Re-added my refactoring to manager.py
stevelittlefish Jun 8, 2026
0319c04
Cautious check when setting flow am project id
stevelittlefish Jun 8, 2026
c0105ac
Got rid of yet another lazy import!
stevelittlefish Jun 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions python/tank/authentication/flow_auth/_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ def get_authentication_token(self) -> str:
return get_flow_access_token()


# TODO: replace with proper client entry point
def get_flow_client(endpoint_url=None):
"""Return a ready-to-use Flow GQL SDK client with authentication wired in.

Expand Down
78 changes: 27 additions & 51 deletions python/tank/bootstrap/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from .errors import TankBootstrapError
from .configuration import Configuration
from .resolver import ConfigurationResolver
from ..authentication import ShotgunAuthenticator
from ..authentication import ShotgunAuthenticator, flow_auth
from ..pipelineconfig import PipelineConfiguration
from .. import LogManager
from ..errors import TankError
Expand All @@ -33,12 +33,12 @@ class ToolkitManager(object):
# Constants used to make the manager bootstrapping:
# - download and cache the config dependencies needed to run the engine being started in a specific environment.
# - download and cache all the config dependencies needed to run the engine in any environment.
(CACHE_SPARSE, CACHE_FULL) = range(2)
CACHE_SPARSE, CACHE_FULL = range(2)

# Constants used to indicate that the manager is:
# - bootstrapping the toolkit (with method bootstrap_toolkit),
# - starting up the engine (with method _start_engine).
(TOOLKIT_BOOTSTRAP_PHASE, ENGINE_STARTUP_PHASE) = range(2)
TOOLKIT_BOOTSTRAP_PHASE, ENGINE_STARTUP_PHASE = range(2)

# List of constants representing the status of the progress bar when these event occurs during bootstrap.
_RESOLVING_PROJECT_RATE = 0.0
Expand Down Expand Up @@ -941,16 +941,13 @@ def _resolve_project_id(self, entity):
raise TankBootstrapError("Cannot resolve project for %s" % entity)
return data["project"]["id"]

def _check_and_trigger_am_auth(self, entity, progress_callback):
def _trigger_am_auth(self, entity, progress_callback):
"""
If the resolved project is AM-ready, proactively obtain a Flow/MEDM
access token. Silent path (file store -> refresh) is tried first; falls
Proactively obtain a Flow/MEDM access token.
Silent path (file store -> refresh) is tried first; falls
back to opening a browser for PKCE if no usable cached/refresh token
exists.

No-op for non-AM projects or when ``entity`` is None. The project and
its AM-ready field are resolved in a single ShotGrid request.

Configuration errors raise ``TankBootstrapError`` (deployment bug).
Runtime auth failures are logged and swallowed unless the
``TK_FLOW_AUTH_REQUIRED`` env var is set to ``"1"``, in which case
Expand All @@ -962,45 +959,9 @@ def _check_and_trigger_am_auth(self, entity, progress_callback):
Set to ``None`` to use the default callback function.
:rtype: None
"""
from ..authentication import flow_auth

if entity is None:
return

am_field = flow_auth.AM_READY_PROJECT_FIELD

if entity.get("type") == "Project":
project_id = entity["id"]
sg_project = self._sg_connection.find_one(
"Project", [["id", "is", project_id]], [am_field]
)
elif "project" in entity and entity["project"].get("type") == "Project":
project_id = entity["project"]["id"]
sg_project = self._sg_connection.find_one(
"Project", [["id", "is", project_id]], [am_field]
)
else:
# Fetch the project link and AM-ready field in a single request
# using ShotGrid's deep-field notation, saving one API round-trip.
data = self._sg_connection.find_one(
entity["type"],
[["id", "is", entity["id"]]],
["project", "project.Project.%s" % am_field],
)
if not data or not data.get("project"):
return
project_id = data["project"]["id"]
sg_project = {am_field: data.get("project.Project.%s" % am_field)}

if not sg_project or not sg_project.get(am_field):
return

log.info("Project %s is AM-ready; triggering MEDM auth.", project_id)
self._report_progress(
progress_callback,
self._UPDATING_CONFIGURATION_RATE,
"Authenticating with Autodesk identity...",
)
try:
settings = flow_auth.resolve_flow_auth_settings()
flow_auth.init_authentication(settings)
Expand All @@ -1009,14 +970,12 @@ def _check_and_trigger_am_auth(self, entity, progress_callback):
flow_auth.get_access_token()
except flow_auth.FlowAuthConfigurationError as e:
raise TankBootstrapError(
"MEDM auth misconfigured for AM-ready project %s: %s"
% (project_id, e)
"MEDM auth misconfigured for AM-ready project: %s" % e
)
except Exception as e:
if os.environ.get("TK_FLOW_AUTH_REQUIRED") == "1":
raise TankBootstrapError(
"MEDM auth failed for AM-ready project %s: %s"
% (project_id, e)
"MEDM auth failed for AM-ready project: %s" % e
)
log.warning(
"MEDM auth failed; bootstrap will continue without a "
Expand Down Expand Up @@ -1090,7 +1049,9 @@ def _get_configuration(self, entity, progress_callback):

elif self._do_shotgun_config_lookup:
# do the full resolve where we connect to shotgun etc.
log.debug("Checking for pipeline configuration overrides in Flow Production Tracking.")
log.debug(
"Checking for pipeline configuration overrides in Flow Production Tracking."
)
log.debug(
"In order to turn this off, set do_shotgun_config_lookup to False"
)
Expand Down Expand Up @@ -1156,7 +1117,22 @@ def _get_updated_configuration(self, entity, progress_callback):
else:
raise TankBootstrapError("Unknown configuration update status!")

self._check_and_trigger_am_auth(entity, progress_callback)
if entity is not None:
project_id = self._resolve_project_id(entity)
if project_id:
sg_project = self._sg_connection.find_one(
"Project",
[["id", "is", project_id]],
[flow_auth.AM_READY_PROJECT_FIELD],
)
if sg_project and sg_project.get(flow_auth.AM_READY_PROJECT_FIELD):
# Retrieve and cache the flow am project id on the context object
flow_project_id = sg_project.get(flow_auth.AM_READY_PROJECT_FIELD)
tk, _ = config.get_tk_instance(self._sg_user)
ctx = tk.context_from_entity_dictionary(entity)
ctx.project[flow_auth.AM_READY_PROJECT_FIELD] = flow_project_id
# Authenticate into Flow AM
self._trigger_am_auth(entity, progress_callback)

return config

Expand Down
45 changes: 37 additions & 8 deletions python/tank/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

from tank_vendor import yaml
from . import authentication
from .authentication import flow_auth

from .util import login
from .util import shotgun_entity
Expand Down Expand Up @@ -403,6 +404,22 @@ def additional_entities(self):
"""
return self.__additional_entities

@property
def flow_am_project_id(self):
"""
The FlowAM project ID for this context, or ``None`` if the project is
not FlowAM-enabled or there is no project in this context.

The value is read from the ``sg_flow_am_id`` field on the project dict.
It is populated by the bootstrap manager after it queries ShotGrid.

:returns: A string containing the FlowAM project ID, or ``None``.
:rtype: str or None
"""
if self.project:
return self.project.get(flow_auth.AM_READY_PROJECT_FIELD)
Comment thread
stevelittlefish marked this conversation as resolved.
return None

@property
def entity_locations(self):
"""
Expand Down Expand Up @@ -815,8 +832,8 @@ def deserialize(cls, context_str):
def to_dict(self):
"""
Converts the context into a dictionary with keys ``project``,
``entity``, ``user``, ``step``, ``task``, ``additional_entities`` and
``source_entity``.
``entity``, ``user``, ``step``, ``task``, ``additional_entities``,
``source_entity``, and ``flow_am_project_id``.

.. note ::
Contrary to :meth:`Context.serialize`, this method discards information
Expand All @@ -835,6 +852,7 @@ def to_dict(self):
self._cleanup_entity(entity) for entity in self.additional_entities
],
"source_entity": self._cleanup_entity(self.source_entity),
"flow_am_project_id": self.flow_am_project_id,
}

def _cleanup_entity(self, entity):
Expand Down Expand Up @@ -894,7 +912,7 @@ def _from_dict(cls, data):

:returns: :class:`Context`
"""
return Context(
ctx = Context(
tk=data.get("tk"),
project=data.get("project"),
entity=data.get("entity"),
Expand All @@ -904,6 +922,13 @@ def _from_dict(cls, data):
additional_entities=data.get("additional_entities"),
source_entity=data.get("source_entity"),
)
if (
ctx.project is not None
and "flow_am_project_id" in data
and flow_auth.AM_READY_PROJECT_FIELD not in ctx.project
):
ctx.project[flow_auth.AM_READY_PROJECT_FIELD] = data["flow_am_project_id"]
Comment thread
stevelittlefish marked this conversation as resolved.
return ctx

################################################################################################
# private methods
Expand Down Expand Up @@ -1305,7 +1330,8 @@ def _from_entity_type_and_id(tk, entity, source_entity=None):

if sg_entity is None:
raise TankError(
"Entity %s with id %s not found in Flow Production Tracking!" % (entity_type, entity_id)
"Entity %s with id %s not found in Flow Production Tracking!"
% (entity_type, entity_id)
)

if sg_entity.get("task"):
Expand Down Expand Up @@ -1719,7 +1745,7 @@ def context_yaml_representer(dumper, context):
# pipeline config path as part of the dict
context_dict["_pc_path"] = context.tank.pipeline_configuration.get_path()

return dumper.represent_mapping(u"!TankContext", context_dict)
return dumper.represent_mapping("!TankContext", context_dict)


def context_yaml_constructor(loader, node):
Expand Down Expand Up @@ -1750,7 +1776,7 @@ def context_yaml_constructor(loader, node):


yaml.add_representer(Context, context_yaml_representer)
yaml.add_constructor(u"!TankContext", context_yaml_constructor)
yaml.add_constructor("!TankContext", context_yaml_constructor)

################################################################################################
# utility methods
Expand Down Expand Up @@ -1807,7 +1833,9 @@ def _task_from_sg(tk, task_id, additional_fields=None):
"Task", [["id", "is", task_id]], standard_fields + additional_fields
)
if not task:
raise TankError("Unable to locate Task with id %s in Flow Production Tracking" % task_id)
raise TankError(
"Unable to locate Task with id %s in Flow Production Tracking" % task_id
)

# add task so it can be processed with other shotgun entities
task["task"] = {"type": "Task", "id": task_id, "name": task["content"]}
Expand Down Expand Up @@ -1867,7 +1895,8 @@ def _entity_from_sg(tk, entity_type, entity_id):

if not data:
raise TankError(
"Unable to locate %s with id %s in Flow Production Tracking" % (entity_type, entity_id)
"Unable to locate %s with id %s in Flow Production Tracking"
% (entity_type, entity_id)
)

# create context
Expand Down
72 changes: 13 additions & 59 deletions tests/bootstrap_tests/test_manager_flow_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,79 +25,33 @@
"tank.authentication.ShotgunAuthenticator.get_user",
return_value=mock.Mock(),
)
class FlowAuthHookTests(ShotgunTestBase):
"""Coverage for ToolkitManager._check_and_trigger_am_auth."""
class TriggerAmAuthTests(ShotgunTestBase):
"""Coverage for ToolkitManager._trigger_am_auth."""

PROJECT_ID = 42

def _build_manager_with_sg(self, sg_project_payload):
"""Create a ToolkitManager whose _sg_connection.find_one returns
``sg_project_payload`` for a Project query."""
mgr = ToolkitManager()
mgr._sg_connection = mock.Mock()
mgr._sg_connection.find_one.return_value = sg_project_payload
return mgr

@mock.patch("tank.authentication.flow_auth.get_access_token")
@mock.patch("tank.authentication.flow_auth.init_authentication")
@mock.patch("tank.authentication.flow_auth.resolve_flow_auth_settings")
def test_am_ready_project_triggers_auth(self, mock_resolve, mock_init, mock_get, _):
def test_triggers_auth(self, mock_resolve, mock_init, mock_get, _):
mock_resolve.return_value = mock.Mock()
mgr = self._build_manager_with_sg({flow_auth.AM_READY_PROJECT_FIELD: "abc-123"})
mgr = ToolkitManager()

mgr._check_and_trigger_am_auth(
mgr._trigger_am_auth(
{"type": "Project", "id": self.PROJECT_ID}, progress_callback=mock.Mock()
)

mock_resolve.assert_called_once()
mock_init.assert_called_once_with(mock_resolve.return_value)
mock_get.assert_called_once()

@mock.patch("tank.authentication.flow_auth.get_access_token")
@mock.patch("tank.authentication.flow_auth.init_authentication")
def test_non_am_ready_project_skips_auth(self, mock_init, mock_get, _):
mgr = self._build_manager_with_sg({flow_auth.AM_READY_PROJECT_FIELD: None})

mgr._check_and_trigger_am_auth(
{"type": "Project", "id": self.PROJECT_ID}, progress_callback=None
)

mock_init.assert_not_called()
mock_get.assert_not_called()

@mock.patch("tank.authentication.flow_auth.get_access_token")
@mock.patch("tank.authentication.flow_auth.init_authentication")
def test_missing_project_entity_skips_auth(self, mock_init, mock_get, _):
mgr = self._build_manager_with_sg(None)

mgr._check_and_trigger_am_auth(
{"type": "Project", "id": self.PROJECT_ID}, progress_callback=None
)

mock_init.assert_not_called()
mock_get.assert_not_called()

@mock.patch("tank.authentication.flow_auth.get_access_token")
@mock.patch("tank.authentication.flow_auth.init_authentication")
def test_empty_project_entity_skips_auth(self, mock_init, mock_get, _):
mgr = self._build_manager_with_sg({})

mgr._check_and_trigger_am_auth(
{"type": "Project", "id": self.PROJECT_ID}, progress_callback=None
)

mock_init.assert_not_called()
mock_get.assert_not_called()

@mock.patch("tank.authentication.flow_auth.get_access_token")
@mock.patch("tank.authentication.flow_auth.init_authentication")
def test_none_entity_skips_auth(self, mock_init, mock_get, _):
mgr = self._build_manager_with_sg({flow_auth.AM_READY_PROJECT_FIELD: "abc-123"})
mgr = ToolkitManager()

mgr._check_and_trigger_am_auth(None, progress_callback=None)
mgr._trigger_am_auth(None, progress_callback=None)

# find_one should not even be called when entity is None
mgr._sg_connection.find_one.assert_not_called()
mock_init.assert_not_called()
mock_get.assert_not_called()

Expand All @@ -108,10 +62,10 @@ def test_configuration_error_raises_TankBootstrapError(
):
mock_resolve.return_value = mock.Mock()
mock_init.side_effect = flow_auth.FlowAuthConfigurationError("missing app id")
mgr = self._build_manager_with_sg({flow_auth.AM_READY_PROJECT_FIELD: "abc-123"})
mgr = ToolkitManager()

with self.assertRaises(TankBootstrapError):
mgr._check_and_trigger_am_auth(
mgr._trigger_am_auth(
{"type": "Project", "id": self.PROJECT_ID},
progress_callback=mock.Mock(),
)
Expand All @@ -124,10 +78,10 @@ def test_runtime_error_soft_fails_by_default(
):
mock_resolve.return_value = mock.Mock()
mock_get.side_effect = RuntimeError("network down")
mgr = self._build_manager_with_sg({flow_auth.AM_READY_PROJECT_FIELD: "abc-123"})
mgr = ToolkitManager()

# Should not raise.
mgr._check_and_trigger_am_auth(
mgr._trigger_am_auth(
{"type": "Project", "id": self.PROJECT_ID}, progress_callback=mock.Mock()
)

Expand All @@ -139,11 +93,11 @@ def test_runtime_error_hard_fails_with_env_var(
):
mock_resolve.return_value = mock.Mock()
mock_get.side_effect = RuntimeError("network down")
mgr = self._build_manager_with_sg({flow_auth.AM_READY_PROJECT_FIELD: "abc-123"})
mgr = ToolkitManager()

with temp_env_var(TK_FLOW_AUTH_REQUIRED="1"):
with self.assertRaises(TankBootstrapError):
mgr._check_and_trigger_am_auth(
mgr._trigger_am_auth(
{"type": "Project", "id": self.PROJECT_ID},
progress_callback=mock.Mock(),
)
Expand Down
Loading