diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..45e303b --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,17 @@ + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/profiles_settings.xml b/.idea/inspectionProfiles/profiles_settings.xml new file mode 100644 index 0000000..105ce2d --- /dev/null +++ b/.idea/inspectionProfiles/profiles_settings.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..a6218fe --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,7 @@ + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..8dbe7f1 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/python-sdk-samples.iml b/.idea/python-sdk-samples.iml new file mode 100644 index 0000000..d0876a7 --- /dev/null +++ b/.idea/python-sdk-samples.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000..6524649 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + { + "associatedIndex": 6 +} + + + + { + "keyToString": { + "RunOnceActivity.OpenProjectViewOnStart": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "WebServerToolWindowFactoryState": "false", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + + + 1721594219300 + + + + + + + + + + file://$PROJECT_DIR$/bulk_assets_creation/assets_uploader.py + + + + + \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..dbbc9ca --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,7 @@ +TBD IF THIS FILE IS NEEDED AT ROOT + +Unity Cloud Python SDK copyright © 2023 Unity Technologies SF + +Licensed under the Unity Terms of Service ( see https://unity.com/legal/terms-of-service.). + +Unless expressly provided otherwise, the Software under this license is made available strictly on an "AS IS" BASIS WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. Please review the license for details on these and other terms and conditions. \ No newline at end of file diff --git a/README.md b/README.md index 355d488..69c626f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,13 @@ -# unity-cloud-python-sdk-samples -Public slack channel: [#uc-cs-am-python-sdk](https://unity.slack.com/messages/C04R01SGG68/)
-[View this project in Backstage](https://backstage.corp.unity3d.com/catalog/default/component/unity-cloud-python-sdk-samples)
-# Converting to public repository -Any and all Unity software of any description (including components) (1) whose source is to be made available other than under a Unity source code license or (2) in respect of which a public announcement is to be made concerning its inner workings, may be licensed and released only upon the prior approval of Legal. -The process for that is to access, complete, and submit this [FORM](https://airtable.com/appj757BYrNIMuTBI/shriEdWiQuxWmJOku). +# Python SDK samples + +This repository exposes a few samples that demonstrate the use of Python SDK in real life use-cases: + +- [Asset Manager for Blender](./assets_manager_for_blender_addon/README.md) +- [Bulk Upload CLI](./bulk_upload_cli/README.md) +- [Bulk Download script](./bulk_download_script/README.md) + +> **Note**: This repository does not accept pull requests, review requests, or any other GitHub-hosted issue management requests. + +## Tell us what you think! + +Thank you for taking a look at the project! To help us improve and provide greater value, please consider providing feedback in our [Help & Support page](https://cloud.unity.com/home/dashboard-support). Thank you! \ No newline at end of file diff --git a/assets_manager_for_blender_addon/CHANGELOG.md b/assets_manager_for_blender_addon/CHANGELOG.md new file mode 100644 index 0000000..10d874f --- /dev/null +++ b/assets_manager_for_blender_addon/CHANGELOG.md @@ -0,0 +1,76 @@ +# Changelog +All notable changes to this package will be documented in this file. + +The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) +and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). + +## [0.6.0] - 2024-07-18 + +### Added +- Ability to select a collection at publish time, to automatically link the asset to. + +### Changed +- When selecting an organization, the first project in it is no longer selected by default. Thus preventing loading a project' assets when selecting an organization. +- Refactored the content of the Source folder to improve readability and clarity +- At publish time, asset is now automatically frozen. +- Thumbnail is published locally temporarily (as we disabled the transformation) + +## [0.5.0] - 2024-05-23 + +### Added +- Add version selection whenever an asset is being updated. + +### Changed +- Upgraded Python SDK dependency to 0.8.2. + +## [0.4.0] - 2024-04-19 + +### Fixed +- Fix new assets creation failing due to the new assets versioning. + +## [0.3.0] - 2024-03-14 + +### Added +- Added `embed textures` checkbox to `Unity Cloud` menu +- Added a preview re-generation when an asset is updated. + +### Changed +- AM4B is now using Unity Cloud Python SDK 0.6.0 + +## [0.2.0] - 2024-02-22 + +### Added +- Added `Login` and `Logout` buttons to `Unity Cloud` menu. +- Added functionality to select and update an asset in current project. +- Leading and trailing spaces in asset name input string are now removed to comply with validation rules. + +### Fixed +- Plugin now recalls previously selected organization and project ids. +- Plugin recalls previously selected organization, project and asset ids only if they are available. +- Removed reference to `InteropException`. + +### Changed +- Updated a few URLs in the documentation. +- `unity-cloud` is initialized/uninitialized on add-on registration/unregistration. +- Previously selected organization, project and asset ids are reset after logging out. +- AM4B is now using Unity Cloud Python SDK 0.5.0 + +### Removed +- Removed the `generate preview` checkbox since previews are now automatically generated for an asset created with a single `.fbx` file. + +## [0.1.1] - 2023-11-30 + +### Changed +- AM4B is now using Unity Cloud Python SDK 0.2.2 + +## [0.1.0] - 2023-11-15 + +Initial release + +Features: +- Source code of "Asset Manager for Blender" add-on that demonstrates how to: + - install Unity Cloud Python SDK in integration environment; + - initialize and uninitialize unity-cloud package; + - login to Asset Manager using Unity Cloud Python SDK; + - create new asset and upload data to it. +- Python script to create a ready-to-install Blender add-on. \ No newline at end of file diff --git a/assets_manager_for_blender_addon/Documentation/Images/enable_addon.png b/assets_manager_for_blender_addon/Documentation/Images/enable_addon.png new file mode 100644 index 0000000..624ab0a Binary files /dev/null and b/assets_manager_for_blender_addon/Documentation/Images/enable_addon.png differ diff --git a/assets_manager_for_blender_addon/Documentation/Images/install_addon.png b/assets_manager_for_blender_addon/Documentation/Images/install_addon.png new file mode 100644 index 0000000..2209e66 Binary files /dev/null and b/assets_manager_for_blender_addon/Documentation/Images/install_addon.png differ diff --git a/assets_manager_for_blender_addon/Documentation/Images/login.png b/assets_manager_for_blender_addon/Documentation/Images/login.png new file mode 100644 index 0000000..10d1b92 Binary files /dev/null and b/assets_manager_for_blender_addon/Documentation/Images/login.png differ diff --git a/assets_manager_for_blender_addon/Documentation/Images/login_complete.png b/assets_manager_for_blender_addon/Documentation/Images/login_complete.png new file mode 100644 index 0000000..495bd5b Binary files /dev/null and b/assets_manager_for_blender_addon/Documentation/Images/login_complete.png differ diff --git a/assets_manager_for_blender_addon/Documentation/Images/logout.png b/assets_manager_for_blender_addon/Documentation/Images/logout.png new file mode 100644 index 0000000..e083252 Binary files /dev/null and b/assets_manager_for_blender_addon/Documentation/Images/logout.png differ diff --git a/assets_manager_for_blender_addon/Documentation/Images/logout_complete.png b/assets_manager_for_blender_addon/Documentation/Images/logout_complete.png new file mode 100644 index 0000000..61b5e8d Binary files /dev/null and b/assets_manager_for_blender_addon/Documentation/Images/logout_complete.png differ diff --git a/assets_manager_for_blender_addon/Documentation/Images/open_addon.png b/assets_manager_for_blender_addon/Documentation/Images/open_addon.png new file mode 100644 index 0000000..ec9c0ef Binary files /dev/null and b/assets_manager_for_blender_addon/Documentation/Images/open_addon.png differ diff --git a/assets_manager_for_blender_addon/Documentation/Images/popup.png b/assets_manager_for_blender_addon/Documentation/Images/popup.png new file mode 100644 index 0000000..13efe7e Binary files /dev/null and b/assets_manager_for_blender_addon/Documentation/Images/popup.png differ diff --git a/assets_manager_for_blender_addon/Documentation/technical-overview.md b/assets_manager_for_blender_addon/Documentation/technical-overview.md new file mode 100644 index 0000000..505e488 --- /dev/null +++ b/assets_manager_for_blender_addon/Documentation/technical-overview.md @@ -0,0 +1,98 @@ +# Technical overview + +This document will describe the structure of the "Asset Manager for Blender", explain functionality of the main modules and functions used in this repository. + +## Table of contents + +- [Technical overview](#technical-overview) + - [Table of contents](#table-of-contents) + - [Add-on structure](#add-on-structure) + - [Add-on registration](#add-on-registration) + - [Installing Unity Cloud Python SDK in Blender's python runtime](#installing-unity-cloud-python-sdk-in-blenders-python-runtime) + - [Initialization of Unity Cloud Python SDK](#initialization-of-unity-cloud-python-sdk) + - [Interface](#interface) + - [Add-on menu](#add-on-menu) + - [Login](#login) + - [Logout](#logout) + - [Add-on dialog](#add-on-dialog) + - [Listing organizations, projects and assets](#listing-organizations-projects-and-assets) + - [Uploading data to Asset Manager](#uploading-data-to-asset-manager) + - [Asset data generation](#asset-data-generation) + - [See also](#see-also) + +## Add-on structure + +The AM4B is essentially a set of Python wheel files, that can be installed into Blender as an add-on. +AM4B contains 3 files: +- `__init__.py` contains data that is required by Blender in order to properly register and display the add-on. +- `uc_wheel_installation.py` module installs Unity Cloud Python SDK it in Blender environment. +- `uc_asset_manager.py` module provides access to Asset Manager through Unity Cloud Python SDK. + +## Add-on registration + +`__init__.py` provides information that is required by Blender in order to display the add-on (see [Add-on Tutorial](https://docs.blender.org/manual/en/latest/advanced/scripting/addon_tutorial.html#what-is-an-add-on) and [Requirements for contributed Scripts](https://wiki.blender.org/wiki/Process/Addons/Guidelines) for details): +- `bl_info`: provides add-on name, description, category, minimum Blender version that the add-on is compatible with. +- `register()` function: initiates installation of Unity Cloud Python SDK in Blender environment, registers Blender operators and displays add-on as Blender scene menu item. +- `unregister()` function: unregisters operators and hides add-on menu item. + +## Installing Unity Cloud Python SDK in Blender's python runtime + +To install Unity Cloud Python SDK, `__init.py__.register()` executes `install_unity_cloud()` function of `uc_wheel_installation` module. The function identifies the current operating system, checks if it is supported by Unity Cloud Python SDK, selects an according Python wheel file in the add-on zip, and installs it using `pip` command. + +## Initialization of Unity Cloud Python SDK + +To initialize Unity Cloud Python SDK, `__init.py__.register()` executes `initialize()` function of `uc_asset_manager` module. The function initializes Unity Cloud Python SDK and configures it to use user login. This operation is performed when add-on was enabled, or when Blender starts. +To uninitialize, `__init.py__.unregister()` executes `uninitialize()` function of `uc_asset_manager` module. This operation is performed when add-on was disabled, or when Blender is closing. + +## Interface + +To define interface of AM4B `__init__.py` implements `UC_Category` and `UploadToCloudOperator` classes, and `draw_func()` function: +- `draw_func()` function displays add-on menu; +- `UC_Category` class describes add-on menu structure and defines menu items functionality; +- `LoginToCloudOperator` class describes functionality of "Login" menu item; +- `LogoutFromCloudOperator` class describes functionality of "Logout" menu item; +- `UploadToCloudOperator` class describes functionality of "Upload FBX to Asset Manager" menu item. + +These classes and function must be registered in Blender to display add-on when it is enabled, and unregistered to hide - when disabled. + +### Add-on menu + +Add-on menu items are defined with `DispatcherMenu` class and `draw_func` function. See [Blender documentation. Menus](https://docs.blender.org/api/current/bpy.types.Menu.html#menu-bpy-struct) for more information about custom menus in Blender. +`draw_func` function is registered in Blender UI system to display menu structure, described in `DispatcherMenu`, when add-on is enabled. + +`DispatcherMenu` class describes the add-on menu and sub-menu items. It provides labels for menu items and define what happens when clicked. + +### Login + +When `Unity Cloud`->`Login` menu is clicked, the add-on will try to perform login to Unity Asset Manager using Unity Cloud Python SDK `identity` module. This functionality is defined in `LoginOperator` class. See [Blender Documentation. Operators](https://docs.blender.org/api/current/bpy.ops.html) for more information about custom operators in Blender. + +### Logout + +When `Unity Cloud`->`Logout` menu is clicked, the add-on will try to perform logout from Unity Asset Manager using Unity Cloud Python SDK `identity` module. This functionality is defined in `LogoutOperator` class. See [Blender Documentation. Operators](https://docs.blender.org/api/current/bpy.ops.html) for more information about custom operators in Blender. + +### Add-on dialog + +When `Unity Cloud`->`Upload FBX to Asset Manager` menu is clicked, the `Upload FBX to Asset Manager` dialog opens. The UI and functionality of the dialog is defined by `UploadOperator` class. See [Blender Documentation. Operators](https://docs.blender.org/api/current/bpy.ops.html) for more information about custom operators in Blender. + +See [Blender Documentation. Property definitions](https://docs.blender.org/api/current/bpy.props.html) and [Window Manager](https://docs.blender.org/api/current/bpy.types.WindowManager.html) for more details about custom dialogs in Blender. + +## Listing organizations, projects and assets + +As mentioned above, access to Asset Manager is provided by `uc_asset_manager` module which uses Unity Cloud Python SDK. The module should be initialized before usage, and uninitialized when it's not in use (See [Initialization of Unity Cloud Python SDK](#initialization-of-unity-cloud-python-sdk) for more information about initialization). +`UploadOperator` prepares the list of the available organizations, projects and assets. + +## Uploading data to Asset Manager + +When user clicks "OK", `UploadOperator.execute()` function is called. It gathers information from user input, and calls `uc_asset_manager` to create asset or a new version, then update it, and finally the payload. + +### Asset data generation + +The first step of uploading to AM4B is FBX file. `_publish_fbx_to_asset` module creates a temporary folder on disk, saves the current scene as fbx in this folder using Python and Blender API. Then the data is sent to `uc_asset_manager` to be uploaded. The temp directory and files will be deleted after the upload: + +See [Blender Documentation. Export scene operators](https://docs.blender.org/api/current/bpy.ops.export_scene.html#module-bpy.ops.export_scene) and [Render Operators](https://docs.blender.org/api/current/bpy.ops.render.html#module-bpy.ops.render) for information about exporting scene and creating images in Blender. + +## See also + +- [Asset Manager for Blender](../README.md) +- [Unity Cloud Python SDK documentation](https://docs.unity.com/cloud/en-us/asset-manager/python-sdk) +- [How to create your custom integration](https://docs.unity.com/cloud/en-us/asset-manager/create-own-integration) diff --git a/assets_manager_for_blender_addon/GPL-license.txt b/assets_manager_for_blender_addon/GPL-license.txt new file mode 100644 index 0000000..e8c0353 --- /dev/null +++ b/assets_manager_for_blender_addon/GPL-license.txt @@ -0,0 +1,340 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc. + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Library General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program; if not, write to the Free Software + Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Library General +Public License instead of this License. diff --git a/assets_manager_for_blender_addon/README.md b/assets_manager_for_blender_addon/README.md new file mode 100644 index 0000000..71411c1 --- /dev/null +++ b/assets_manager_for_blender_addon/README.md @@ -0,0 +1,192 @@ +# Asset Manager for Blender + +AM4 Blender add-on allows you to integrate Unity Cloud Asset Manager service within your [Blender](https://www.blender.org/) workflows. +This repository open sources the code of that add-on, so that you can get inspired by it or build on top of it - to create your own custom Asset Manager integration flows for Blender or any other software. + +To connect and find support, join the [Help & Support page](https://cloud.unity.com/home/dashboard-support)! + +## Table of contents +- [Asset Manager for Blender](#asset-manager-for-blender) + - [Table of contents](#table-of-contents) + - [Prerequisites](#prerequisites) + - [System requirements](#system-requirements) + - [Supported platforms](#supported-platforms) + - [Licenses](#licenses) + - [How do I...?](#how-do-i) + - [Build the add-on](#build-the-add-on) + - [Install the add-on](#install-the-add-on) + - [Login to Unity Cloud Asset Manager](#login-to-unity-cloud-asset-manager) + - [Upload the 3D view as a new asset to Unity Cloud Asset Manager](#upload-the-3d-view-as-a-new-asset-to-unity-cloud-asset-manager) + - [Upload the 3D view to an existing asset in Unity Cloud Asset Manager](#upload-the-3d-view-to-an-existing-asset-in-unity-cloud-asset-manager) + - [Logout from Unity Cloud Asset Manager](#logout-from-unity-cloud-asset-manager) + - [Troubleshooting](#troubleshooting) + - [CERTIFICATE\_VERIFY\_FAILED when building the add-on on MacOS](#certificate_verify_failed-when-building-the-add-on-on-macos) + - [Trouble when installing the addon](#trouble-when-installing-the-addon) + - [Failed to upload asset - cannot render, no camera](#failed-to-upload-asset---cannot-render-no-camera) + - [Security limitations](#security-limitations) + - [See also](#see-also) + - [Tell us what you think!](#tell-us-what-you-think) + +## Prerequisites + +### System requirements + +To build the AM4 Blender add-on, you need: +- Python 3.x installed on your machine. + +To install and use the AM4 Blender add-on, you need: +- Blender installed on your machine (guaranteed support from versions 3.x to 4.1.x) +- The right permissions to use Asset Manager. See [Get Started with Asset Manager](https://docs.unity.com/cloud/en-us/asset-manager/get-started) for more details. + +### Supported platforms + +AM4 Blender is compatible with: + +- Windows x64 +- Mac x64/Arm64 + +### Licenses + +The Unity Cloud Asset Manager for Blender sample is made available under the [GPL v2 license](GPL-license.txt). + +## How do I...? + +### Build the add-on + +Run the `pack-addon.py` script to create a ZIP file that can be installed in Blender as a plugin. + +``` +cd .\Scripts +python pack-addon.py (-dw | -lw [LOCAL]) [-n NAME] [-o OUTPUT] [-os {windows,macos,all}] +``` + +Option | Description +---|--- +`-dw, --download` | Download Unity Cloud Python SDK dependency. +`-lw [LOCAL], --local [LOCAL]` | Specify a local folder to copy Unity Cloud Python SDK dependency from. +`-o OUTPUT, --output OUTPUT` | Specify a folder to save the add-on archive in. By default, will create a `Dist` folder at the root of the repository. +`-os {windows,macos,all}, --system {windows,macos,all}` | Specify target platform. By default `all`. + +### Install the add-on + +To install the add-on, follow these steps: + +1. Open Blender. +2. Go to **Edit** > **Preferences**. +3. Go to the **Add-ons** section. +4. Select **Install**. + +![installing the add-on](Documentation/Images/install_addon.png) +5. Select the `UCAM4Blender.zip` file that you built, then select **Install Add-on**. +6. Check the checkbox to enable the add-on. The `Unity Cloud` tab appears in your 3D view. + +![enabling the add-on](Documentation/Images/enable_addon.png) + +### Login to Unity Cloud Asset Manager + +Follow these steps if is the first time you run the add-on or you have previously logged out (See [Logout from Unity Cloud Asset Manager](#logout-from-unity-cloud-asset-manager) for information about logout). Otherwise, add-on will automatically log in using the previous session. +1. From your 3D view, go to **Unity Cloud** > **Login**. + +![login-to-am](Documentation/Images/login.png) + +> **Note**: You will be automatically redirected to the Unity login page. Make sure you complete the login process, until you are redirected to the following page. +> ![login complete](Documentation/Images/login_complete.png) + +2. Go back to Blender. + +### Upload the 3D view as a new asset to Unity Cloud Asset Manager + +1. Ensure you are logged in to Asset Manager (See [Login to Unity Cloud Asset Manager](#login-to-unity-cloud-asset-manager) for more information about login). +2. From your 3D view, go to **Unity Cloud** > **Upload FBX to Asset Manager**. + +![opening the add-on](Documentation/Images/open_addon.png) + +3. You should now see the `Upload FBX to Asset Manager` popup. + +![popup](Documentation/Images/popup.png) + +4. Select a target organization and a project. If you don't have one, you can refer to the [create a new project guide](https://docs.unity.com/cloud/en-us/asset-manager/new-asset-manager-project). +5. Ensure `` option is selected in `Asset` dropdown. +6. Enter the new asset name, description, collection and tags. As part of the upload process, this information will be assigned to the asset. +> **Note**: To add multiple tags, simply separate them with a space in-between. +7. Check `Embed textures` option to export fbx with textures. This will set path mode to `COPY`, otherwise it will be set to `AUTO`. +8. Select **OK**. +> **Note**: Once the uploading is complete, you are automatically redirected to the Asset Manager dashboard, so that you can perform additional edit and publish operations from there. + +### Upload the 3D view to an existing asset in Unity Cloud Asset Manager + +1. Ensure you are logged in to Asset Manager (See [Login to Unity Cloud Asset Manager](#login-to-unity-cloud-asset-manager) for more information about login) +2. From your 3D view, go to **Unity Cloud** > **Upload FBX to Asset Manager**. + +![opening the add-on](Documentation/Images/open_addon.png) + +3. You should now see the `Upload FBX to Asset Manager` popup. + +![popup](Documentation/Images/popup.png) + +4. Select a target organization and a project. If you don't have one, you can refer to the [create a new project guide](https://docs.unity.com/cloud/en-us/asset-manager/new-asset-manager-project). +5. In `Asset` dropdown select the asset you want to update. Add-on will fetch asset name, asset versions, description and tags. +> **Note**: During uploading, any existing files in the asset will be removed. +6. Select the version you want to update. A new version will be created off of it. +7. Change the asset name, description, collection and tags, if needed. As part of the upload process, this information will be assigned to the asset. +> **Note**: To add multiple tags, simply separate them with a space in-between. + +> **Note**: Selecting a collection will only ensure the asset is added to that collection. It will have no impact on any previously linked collection. +8. Check `Embed textures` option to export fbx with textures. This will set path mode to `COPY`, otherwise it will be set to `AUTO`. +9. Select **OK**. +> **Note**: Once the uploading is complete, you are automatically redirected to the Asset Manager dashboard, so that you can perform additional edit and publish operations from there. + +### Logout from Unity Cloud Asset Manager + +1. From your 3D view, go to **Unity Cloud** > **Logout**. Note, this option is only available when you are logged in. + +![logout-from-am](Documentation/Images/logout.png) + +> **Note**: Once logout completes, you will be automatically redirected to the following page. +> ![logout complete](Documentation/Images/logout_complete.png) + +2. Go back to Blender. + +## Troubleshooting + +### CERTIFICATE_VERIFY_FAILED when building the add-on on MacOS + +Part of the add-on building process requires to download the Unity Cloud Python SDK dependency. +Python 3.x does not rely on MacOS' openSSL ; it comes with its own openSSL bundled and doesn't have access on MacOS' root certificates. + +To solve this issue, you have two options: + +*1) Run an install command shipped with Python 3.x* +``` +cd /Applications/Python\ 3.x/ +./Install\ Certificates.command +``` + +*2) Install the certifi package* +``` +pip install certifi +``` + +### Trouble when installing the addon + +1. When installing the addon, there is an automatic process that tries to install the Unity Cloud Python SDK in Blender's integrated python environment. Depending on the setup, this step is prevented by the system and results in an exception (`Error: No module named 'unity_cloud'`). +For those use cases, running Blender with admin privileges might be necessary to make this step possible. Note that this will only be needed once at install time ; any subsequent session of Blender can run without elevated privileges. +2. If you are replacing an version of the addon with another, be sure to close Blender in between. Blender has a tendency to keep outdated code in memory even after you uninstall an addon, which will create conflicts with the new version. + +### Failed to upload asset - cannot render, no camera + +When uploading an asset, you might run into an error `Failed to upload asset to Unity Cloud Asset Manager`, with additional details `RuntimeError: Error: Cannot render, no camera`. +This error is thrown because AM4B uses the camera to render a preview before uploading the asset. If you don't have any camera on your scene, you'll need to add one. + +### Security limitations + +When building the add-on, the `-dw` option does not perform any integrity protection step while downloading the Unity Cloud Python SDK. + +## See also + +- [Technical overview of the add-on](./Documentation/technical-overview.md) +- [Unity Cloud Python SDK documentation](https://docs.unity.com/cloud/en-us/asset-manager/python-sdk) + +## Tell us what you think! + +Thank you for taking a look at the project! To help us improve and provide greater value, please consider providing feedback in our [Help & Support page](https://cloud.unity.com/home/dashboard-support). Thank you! \ No newline at end of file diff --git a/assets_manager_for_blender_addon/Source/__init__.py b/assets_manager_for_blender_addon/Source/__init__.py new file mode 100644 index 0000000..c8f50ee --- /dev/null +++ b/assets_manager_for_blender_addon/Source/__init__.py @@ -0,0 +1,407 @@ +import bpy +import tempfile +import os.path +import shutil +from typing import List +from bpy.types import Menu +from .uc_wheel_installation import install_unity_cloud +from . import uc_asset_manager + +############### Constants + +NO_PROJECT_VALUE = "-1" +CREATE_NEW_ASSET_VALUE = "-1" +NO_COLLECTION_VALUE = "-1" + +DEFAULT_ASSET_NAME = "New asset" + +############### Global variables + +# What is displayed in the dropdowns +global_project_items = [] +global_organization_items = [] +global_assets_items = [] +global_asset_versions_items = [] +global_collections_items = [] + +# Internal cache +global_assets_cache = {} + +# Cache of previously selected values +global_previous_org_id = None +global_previous_project_id = None +global_previous_asset_id = None +global_previous_asset_version_id = None + + +############### Blender hooks + +def on_selected_org_changed(self, context): + _refresh_projects(self, self.org_dropdown) + self.project_dropdown = global_project_items[0][0] + + +def on_selected_project_changed(self, context): + if (self.project_dropdown != None and self.project_dropdown != NO_PROJECT_VALUE): + _refresh_assets(self, self.org_dropdown, self.project_dropdown) + self.asset_dropdown = global_assets_items[0][0] + _refresh_collections(self, self.org_dropdown, self.project_dropdown) + self.collection_dropdown = global_collections_items[0][0] + + +def on_selected_asset_changed(self, context): + if (self.asset_dropdown == CREATE_NEW_ASSET_VALUE): + self.name_input = "New asset" + self.description_input = "" + self.tags_input = "" + else: + _refresh_asset_versions(self, self.org_dropdown, self.project_dropdown, self.asset_dropdown) + self.version_dropdown = global_asset_versions_items[0][0] + + asset = global_assets_cache[self.asset_dropdown] + self.name_input = asset.name + self.description_input = asset.description if asset.description != None else "" + tags = "" + if asset.tags != None: + for tag in asset.tags: + tags += f'{tag} ' + self.tags_input = tags + + +def get_organizations(self, context): + return global_organization_items + + +def get_projects(self, context): + return global_project_items + + +def get_assets(self, context): + return global_assets_items + + +def get_asset_versions(self, context): + return global_asset_versions_items + + +def get_collections(self, context): + return global_collections_items + + +############### Internal methods + +def _refresh_orgs(self): + global global_organization_items + + tmp_items = list() + orgs = uc_asset_manager.get_organizations() + for org in orgs: + tmp_items.append((org.id, org.name, f"{org.name}. {org.id}")) + global_organization_items = tmp_items + + +def _refresh_projects(self, org_id): + global global_project_items + + if org_id != None: + tmp_items = list() + tmp_items.append((NO_PROJECT_VALUE, "", "")) + + projects = uc_asset_manager.get_projects(org_id) + for project in projects: + tmp_items.append((project.id, project.name, f"{project.name}. {project.id}")) + global_project_items = tmp_items + else: + global_project_items = [(NO_PROJECT_VALUE, "", "")] + + +def _refresh_assets(self, org_id, project_id): + global global_assets_items, global_assets_cache + + global_assets_cache = {} + + tmp_items = list() + tmp_items.append((CREATE_NEW_ASSET_VALUE, "", "")) + try: + assets = uc_asset_manager.get_assets(org_id, project_id) + for asset in assets: + if (asset.is_frozen): + tmp_items.append((asset.id, asset.name, f"{asset.name}. {asset.id}")) + global_assets_cache[asset.id] = asset + except Exception as error: + print(f"Failed to get asset list from the project {org_id}/{project_id}") + print(error) + global_assets_items = tmp_items + + +def _refresh_collections(self, org_id, project_id): + global global_collections_items + + tmp_items = list() + tmp_items.append((NO_COLLECTION_VALUE, "", "")) + try: + collections = uc_asset_manager.list_collections(org_id, project_id) + for collection in collections: + tmp_items.append((collection.name, collection.name, collection.description)) + except Exception as error: + print(f"Failed to get collection list from the project {org_id}/{project_id}") + print(error) + global_collections_items = tmp_items + + +def _refresh_asset_versions(self, org_id, project_id, asset_id): + global global_asset_versions_items + + tmp_items = list() + try: + asset_versions = uc_asset_manager.get_asset_versions(org_id, project_id, asset_id) + for asset in asset_versions: + item_name = f"Ver.{asset.frozen_sequence_number}" + if asset.parent_frozen_sequence_number != 0 and not asset.is_frozen: + item_name = f"Ver.{asset.parent_frozen_sequence_number}-Pending-{asset.authoring_info.created}" + + tmp_items.append((asset.version, item_name, f"{asset.name}. v:{asset.version}")) + tmp_items.sort(key=lambda x: x[1]) + tmp_items.reverse() + except Exception as error: + print(f"Failed to get version list for {org_id}/{project_id}/{asset_id}") + print(error) + global_asset_versions_items = tmp_items + + +def _contains_item(lst, item): + return any(triple[0] == item for triple in lst) + + +def _publish_content_to_asset(org_id: str, project_id: str, asset_id: str, version: str, file_name: str, + embed_textures: bool): + temp_dir = tempfile.mkdtemp() + try: + _publish_fbx_to_asset(temp_dir, org_id, project_id, asset_id, version, file_name, embed_textures) + _publish_thumbnail_to_asset(temp_dir, org_id, project_id, asset_id, version) + finally: + shutil.rmtree(temp_dir) + + +def _publish_fbx_to_asset(temp_dir: str, org_id: str, project_id: str, asset_id: str, version: str, file_name: str, + embed_textures: bool): + temp_fbx_file = os.path.join(temp_dir, f"{file_name}.fbx") + + path_mode = 'COPY' if embed_textures else 'AUTO' + bpy.ops.export_scene.fbx(filepath=temp_fbx_file, path_mode=path_mode, embed_textures=embed_textures) + + uc_asset_manager.publish_payload(org_id, project_id, asset_id, version, "Source", file_name, temp_fbx_file) + + +def _publish_thumbnail_to_asset(temp_dir: str, org_id: str, project_id: str, asset_id: str, version: str): + temp_thumbnail_file = os.path.join(temp_dir, "thumbnail.png") + + bpy.context.scene.render.resolution_x = 1024 + bpy.context.scene.render.resolution_y = 1024 + bpy.context.scene.render.image_settings.file_format = 'PNG' + bpy.context.scene.render.filepath = temp_thumbnail_file + bpy.ops.render.render(write_still=True) + + uc_asset_manager.publish_payload(org_id, project_id, asset_id, version, "Preview", "thumbnail", temp_thumbnail_file) + + +############### Defining the menus & operators + +class LoginOperator(bpy.types.Operator): + bl_idname = "uc_addon.addon_login" + bl_label = "Login" + bl_description = "Login to Asset Manager" + + def execute(self, context): + uc_asset_manager.login() + return {'FINISHED'} + + +class LogoutOperator(bpy.types.Operator): + bl_idname = "uc_addon.addon_logout" + bl_label = "Logout" + bl_description = "Logout from Asset Manager" + + def execute(self, context): + try: + uc_asset_manager.logout() + finally: + global_previous_org_id = None + global_previous_project_id = None + global_previous_asset_id = None + global_previous_asset_version_id = None + return {'FINISHED'} + + +class UploadOperator(bpy.types.Operator): + bl_idname = "uc_addon.addon_dialog" + bl_label = "Upload FBX to Asset Manager" + bl_description = "Create a new asset and upload current scene as *.fbx to Asset Manager" + + org_dropdown: bpy.props.EnumProperty( + items=get_organizations, + name="Organization:", + description="Select an organization", + update=on_selected_org_changed, + ) + project_dropdown: bpy.props.EnumProperty( + items=get_projects, + name="Project:", + description="Select a project", + update=on_selected_project_changed, + ) + + asset_dropdown: bpy.props.EnumProperty( + items=get_assets, + name="Asset:", + description="Select an asset", + update=on_selected_asset_changed, + ) + + version_dropdown: bpy.props.EnumProperty( + items=get_asset_versions, + name="Version:", + description="Select an asset version. If the selected version is frozen, a new one will be created", + + ) + + name_input: bpy.props.StringProperty(name="Asset name:", default=DEFAULT_ASSET_NAME) + description_input: bpy.props.StringProperty(name="Asset description:", default="") + + collection_dropdown: bpy.props.EnumProperty( + items=get_collections, + name="Collection:", + description="Select a collection", + ) + + tags_input: bpy.props.StringProperty(name="Tags:", description="Asset tags separated by spaces: tag1 tag2", + default="") + + embed_textures: bpy.props.BoolProperty(name="Embed textures", + description="Embed texture in exported model and set path mode to COPY.", + default=True) + + def execute(self, context): + try: + name = self.name_input.strip() + tags = self.tags_input.split() + collection = self.collection_dropdown + if collection == NO_COLLECTION_VALUE: + collection = None + + if (self.asset_dropdown == CREATE_NEW_ASSET_VALUE): + asset = uc_asset_manager.create_asset(self.org_dropdown, self.project_dropdown, name) + else: + asset = uc_asset_manager.create_asset_version(self.org_dropdown, self.project_dropdown, + self.asset_dropdown, self.version_dropdown) + + uc_asset_manager.update_asset(self.org_dropdown, self.project_dropdown, asset.id, asset.version, name, + self.description_input, tags, collection) + _publish_content_to_asset(self.org_dropdown, self.project_dropdown, asset.id, asset.version, name, + self.embed_textures) + + uc_asset_manager.freeze_asset(self.org_dropdown, self.project_dropdown, asset.id, asset.version) + + uc_asset_manager.open_asset_details(self.org_dropdown, self.project_dropdown, asset.id, asset.version) + except Exception: + self.report({'WARNING'}, "Failed to upload asset to Unity Cloud Asset Manager") + raise + finally: + global global_previous_org_id, global_previous_project_id, global_previous_asset_id, global_previous_asset_version_id + global_previous_org_id = self.org_dropdown + global_previous_project_id = self.project_dropdown + global_previous_asset_id = self.asset_dropdown + global_previous_asset_version_id = self.version_dropdown + return {'FINISHED'} + + def invoke(self, context, event): + _refresh_orgs(self) + + # Try reuse the cached options from last session + if _contains_item(global_organization_items, global_previous_org_id): + self.org_dropdown = global_previous_org_id + + if _contains_item(global_project_items, global_previous_project_id): + self.project_dropdown = global_previous_project_id + + if _contains_item(global_assets_items, global_previous_asset_id): + self.asset_dropdown = global_previous_asset_id + + if _contains_item(global_asset_versions_items, global_previous_asset_version_id): + self.version_dropdown = global_previous_asset_version_id + else: + self.org_dropdown = global_organization_items[0][0] + + return context.window_manager.invoke_props_dialog(self) + + def draw(self, context): + layout = self.layout + layout.prop(self, "org_dropdown", text="Organization") + + if (self.org_dropdown != None): + layout.prop(self, "project_dropdown", text="Project") + + if (self.project_dropdown != None and self.project_dropdown != NO_PROJECT_VALUE): + layout.prop(self, "asset_dropdown", text="Asset") + + if self.asset_dropdown != CREATE_NEW_ASSET_VALUE and len(global_asset_versions_items) > 0: + layout.prop(self, "version_dropdown", text="Version") + + layout.prop(self, "name_input", text="Asset name") + layout.prop(self, "description_input", text="Asset description") + layout.prop(self, "collection_dropdown", text="Collection") + layout.prop(self, "tags_input", text="Tags") + layout.prop(self, "embed_textures", text="Embed textures") + + +class DispatcherMenu(Menu): + bl_idname = "VIEW3D_MT_UC_dispatcher" + bl_label = "Unity Cloud" + + def draw(self, context): + layout = self.layout + + if uc_asset_manager.is_logged_in(): + layout.operator(UploadOperator.bl_idname, text=UploadOperator.bl_label) + layout.operator(LogoutOperator.bl_idname, text=LogoutOperator.bl_label) + else: + layout.operator(LoginOperator.bl_idname, text=LoginOperator.bl_label) + + +############### Registering the menus & operators + +bl_info = { + "name": "Upload FBX to Unity Cloud Asset Manager", + "blender": (2, 93, 0), + "description": "Exports current scene to *.fbx and publishes it as a new asset in Unity Cloud Asset Manager", + "category": "Import-Export" +} + +classes = (DispatcherMenu, UploadOperator, LoginOperator, LogoutOperator) + + +def draw_func(self, context): + self.layout.menu(DispatcherMenu.bl_idname) + + +def register(): + install_unity_cloud() + for cls in classes: + bpy.utils.register_class(cls) + bpy.types.VIEW3D_MT_editor_menus.append(draw_func) + + from . import uc_asset_manager + uc_asset_manager.initialize() + + +def unregister(): + from . import uc_asset_manager + uc_asset_manager.uninitialize() + + for cls in classes: + bpy.utils.unregister_class(cls) + bpy.types.VIEW3D_MT_editor_menus.remove(draw_func) + + +if __name__ == "__main__": + register() diff --git a/assets_manager_for_blender_addon/Source/uc_asset_manager.py b/assets_manager_for_blender_addon/Source/uc_asset_manager.py new file mode 100644 index 0000000..8ecf9a1 --- /dev/null +++ b/assets_manager_for_blender_addon/Source/uc_asset_manager.py @@ -0,0 +1,98 @@ +import unity_cloud as ucam +import pathlib + +from typing import List +from unity_cloud.models import * + +is_initialized = False + + +def initialize(): + global is_initialized + if not is_initialized: + ucam.initialize() + ucam.identity.user_login.use() + is_initialized = True + + +def is_logged_in(): + return ucam.identity.user_login.get_authentication_state() == ucam.identity.user_login.Authentication_State.LOGGED_IN + + +def login(): + if ucam.identity.user_login.get_authentication_state() != ucam.identity.user_login.Authentication_State.LOGGED_IN: + ucam.identity.user_login.login() + + +def logout(): + if ucam.identity.user_login.get_authentication_state() == ucam.identity.user_login.Authentication_State.LOGGED_IN: + ucam.identity.user_login.logout(True) + + +def create_asset(org_id: str, project_id: str, name: str): + asset_creation = AssetCreation(name, ucam.assets.AssetType.MODEL_3D) + return ucam.assets.create_asset(asset_creation, org_id, project_id) + + +def create_asset_version(org_id: str, project_id: str, asset_id: str, parent_version: str): + return ucam.assets.create_unfrozen_asset_version(org_id, project_id, asset_id, parent_version) + + +def update_asset(org_id: str, project_id: str, asset_id: str, version: str, name: str, description: str, + tags: List[str], collection: str): + asset_update = AssetUpdate(name, ucam.assets.AssetType.MODEL_3D, description, tags) + ucam.assets.update_asset(asset_update, org_id, project_id, asset_id, version) + + if (collection != None): + ucam.assets.link_assets_to_collection(org_id, project_id, collection, [asset_id]) + + +def publish_payload(org_id: str, project_id: str, asset_id: str, version: str, dataset_tag: str, file_name: str, path: str): + dataset_list = ucam.assets.get_dataset_list(org_id, project_id, asset_id, version) + + dataset = next(d for d in dataset_list if dataset_tag in d.system_tags) + + # Remove any pending file + file_list = ucam.assets.get_file_list(org_id, project_id, asset_id, version, dataset.id) + for file in file_list: + ucam.assets.remove_file(org_id, project_id, asset_id, version, dataset.id, file.path) + + extension = pathlib.Path(path).suffix + upload_asset = FileUploadInformation(org_id, project_id, asset_id, version, dataset.id, path, + file_name + extension) + ucam.assets.upload_file(upload_asset, disable_automatic_transformations=True) + + +def freeze_asset(org_id: str, project_id: str, asset_id: str, version: str): + ucam.assets.freeze_asset_version(org_id, project_id, asset_id, version, "Updated model via Blender plugin") + + +def open_asset_details(org_id: str, project_id: str, asset_id: str, version: str): + ucam.interop.open_browser_to_asset_details(org_id, project_id, asset_id, version) + + +def get_organizations() -> List[Organization]: + return ucam.identity.get_organization_list() + + +def get_projects(org_id: str) -> List[Project]: + return ucam.identity.get_project_list(org_id) + + +def get_assets(org_id: str, project_id: str) -> List[Asset]: + return ucam.assets.get_asset_list(org_id, project_id) + + +def list_collections(org_id: str, project_id: str) -> List[Collection]: + return ucam.assets.list_collections(org_id, project_id) + + +def get_asset_versions(org_id: str, project_id: str, asset_id: str) -> List[Asset]: + return ucam.assets.search_versions_in_asset(org_id, project_id, asset_id) + + +def uninitialize(): + global is_initialized + if is_initialized: + ucam.uninitialize() + is_initialized = False diff --git a/assets_manager_for_blender_addon/Source/uc_wheel_installation.py b/assets_manager_for_blender_addon/Source/uc_wheel_installation.py new file mode 100644 index 0000000..2a84bb7 --- /dev/null +++ b/assets_manager_for_blender_addon/Source/uc_wheel_installation.py @@ -0,0 +1,39 @@ +import pip +import os +import platform +import glob + + +def get_platform_name(system: str, machine: str) -> str: + name: str + if system == "windows": + if machine == "amd64" or machine == "x86_64": + name = "win_amd64" + elif machine == "arm64": + name = "win_arm64" + elif system == "darwin": + name = "macosx_13_0_universal2" + else: + raise Exception(f"Unsupported configuration: {system}-{machine}") + return name + + +def __get_platform_name(): + system = platform.system().lower() + machine = platform.machine().lower() + return get_platform_name(system, machine) + + +def install_unity_cloud(): + print("Installing unity-cloud package...") + + wheels_folder = f"{os.path.dirname(os.path.abspath(__file__))}/wheels" + matching_files = glob.glob(f"{wheels_folder}/unity_cloud*-py3-none-{__get_platform_name()}.whl") + if len(matching_files) == 1: + if pip.main(['install', matching_files[0], "--force-reinstall"]) == 0: + print("unity-cloud package installed!") + else: + if len(matching_files) == 0: + print("Failed to install unity-cloud package: wheel file for the current platform was not found") + else: + print("Failed to install unity-cloud package: More then one wheel file for the current platform was found") diff --git a/bulk_download_script/LICENSE.md b/bulk_download_script/LICENSE.md new file mode 100644 index 0000000..ca665c4 --- /dev/null +++ b/bulk_download_script/LICENSE.md @@ -0,0 +1,5 @@ +Unity Cloud Python SDK copyright © 2023 Unity Technologies SF + +Licensed under the Unity Terms of Service ( see https://unity.com/legal/terms-of-service.). + +Unless expressly provided otherwise, the Software under this license is made available strictly on an "AS IS" BASIS WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. Please review the license for details on these and other terms and conditions. \ No newline at end of file diff --git a/bulk_download_script/README.md b/bulk_download_script/README.md new file mode 100644 index 0000000..cc2c911 --- /dev/null +++ b/bulk_download_script/README.md @@ -0,0 +1,58 @@ +# Bulk download script + +The sample script demonstrates how to use Python SDK to download assets from Unity Cloud Asset Manager. + +To connect and find support, join the [Help & Support page](https://cloud.unity.com/home/dashboard-support)! + +## Table of contents +- [Bulk download script](#bulk-download-script) + - [Table of contents](#table-of-contents) + - [Prerequisites](#prerequisites) + - [System requirements](#system-requirements) + - [Licenses](#licenses) + - [How do I run the sample ?](#how-do-i-run-the-sample-) + - [1. Edit the `bulk_download.py` script with your requirements](#1-edit-the--bulk_downloadpy-script-with-your-requirements) + - [2. Run the script](#2-run-the-script) + - [See also](#see-also) + - [Tell us what you think!](#tell-us-what-you-think) + +## Prerequisites + +### System requirements + +To run the script, you need: +- Blender 3.x installed on your machine +- An up-to-date Python SDK wheel installed ( > 0.5.0). +- The right permissions to use Asset Manager. See [Get Started with Asset Manager](https://docs.unity.com/cloud/en-us/asset-manager/get-started) for more details. +- A source project with assets manager enable and assets already uploaded in it. + +### Licenses + +The bulk download sample script is made available under the [Unity ToS license](./LICENSE.md). + +## How do I run the sample ? + +To run the sample, follow these steps: + +### 1. Edit the `bulk_download.py` script with your requirements + +In the `main` conditional section, you must edit some information to link the sample to your project. + +- org_id: Your organization id. +- project_id: Your project id. +- download_directory: must be edited with the path where the assets will be downloaded. +- overwrite: When set to `True`, the script will overwrite the files in the download directory if they already exist. Otherwise, it will skip the download. +- include_filter/exclude_filter/any_filter: This dictionary contains the search criteria to fetch the assets. Some example are written in comments, otherwise please refer to [the Python SDK documentation](https://docs.unity.com/cloud/en-us/asset-manager/python-sdk/manage-assets#create-filter-for-a-search-query) to learn how to use search criteria. +- collections: This list contains the collections to fetch the assets from. Leave it empty to search through all the assets in the project. + +### 2. Run the script + +With you favorite command line tool, run `python bulk_download.py` in this folder. + +## See also + +- [Unity Cloud Python SDK documentation](https://docs.unity.com/cloud/en-us/asset-manager/python-sdk) + +## Tell us what you think! + +Thank you for taking a look at the project! To help us improve and provide greater value, please consider providing feedback in our [Help & Support page](https://cloud.unity.com/home/dashboard-support). Thank you! \ No newline at end of file diff --git a/bulk_download_script/__init__.py b/bulk_download_script/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bulk_download_script/bulk_download.py b/bulk_download_script/bulk_download.py new file mode 100644 index 0000000..1b9c873 --- /dev/null +++ b/bulk_download_script/bulk_download.py @@ -0,0 +1,65 @@ +from concurrent.futures.thread import ThreadPoolExecutor + +import unity_cloud as uc +from pathlib import PurePath, Path +from unity_cloud.models import * + + +def login_with_user_account(): + uc.identity.user_login.use() + auth_state = uc.identity.user_login.get_authentication_state() + if auth_state != uc.identity.user_login.Authentication_State.LOGGED_IN: + uc.identity.user_login.login() + + +def download_asset(organization_id: str, project_id: str, asset: Asset, download_path: str, overwrite: bool = False): + dataset = uc.assets.get_dataset_list(organization_id, project_id, asset.id, asset.version)[0] + asset_files = uc.assets.get_file_list(organization_id, project_id, asset.id, asset.version, dataset.id) + + with ThreadPoolExecutor(max_workers=10) as executor: + for file in asset_files: + file_download_info = FileDownloadInformation(organization_id, project_id, asset.id, asset.version, + dataset.id, file.path, PurePath(download_path)) + + target_file = Path(download_path) / file.path + + if not overwrite and target_file.exists(): + print(f"Skipping download of {file.path} as it already exists", flush=True) + continue + + print(f"Downloading file: {file.path}", flush=True) + executor.submit(uc.assets.download_file, file_download_info) + + +def download_assets(assets: [Asset], org_id: str, project_id: str, download_path: str, overwrite: bool = False): + for asset in assets: + print(f"Downloading files for asset: {asset.name}", flush=True) + download_asset(org_id, project_id, asset, download_path, overwrite) + + +if __name__ == '__main__': + + uc.initialize() + login_with_user_account() + + org_id = '' + project_id = '' + download_directory = 'C:\\path\\to\\download\\directory\\' + overwrite = False + + include_filter = dict() + + #to search by status uncomment one of the following lines + #include_filter[SearchableProperties.STATUS] = "Published" + #include_filter[SearchableProperties.STATUS] = "Draft" + + #to search by tags uncomment one of the following lines and replace with the tag you want to search for + #include_filter[SearchableProperties.TAGS] = ["""] + #include_filter[SearchableProperties.FILES_TAGS] = ["""] + + collections = [] + # collections = [''] + + assets = uc.assets.search_assets_in_projects(org_id=org_id, project_ids=[project_id], include_filter=include_filter, + collections=collections) + download_assets(assets, org_id, project_id, download_directory, overwrite=overwrite) diff --git a/bulk_upload_cli/Documentation/group-by-folder.png b/bulk_upload_cli/Documentation/group-by-folder.png new file mode 100644 index 0000000..5028309 Binary files /dev/null and b/bulk_upload_cli/Documentation/group-by-folder.png differ diff --git a/bulk_upload_cli/Documentation/group-by-name.png b/bulk_upload_cli/Documentation/group-by-name.png new file mode 100644 index 0000000..e313c62 Binary files /dev/null and b/bulk_upload_cli/Documentation/group-by-name.png differ diff --git a/bulk_upload_cli/LICENSE.md b/bulk_upload_cli/LICENSE.md new file mode 100644 index 0000000..ca665c4 --- /dev/null +++ b/bulk_upload_cli/LICENSE.md @@ -0,0 +1,5 @@ +Unity Cloud Python SDK copyright © 2023 Unity Technologies SF + +Licensed under the Unity Terms of Service ( see https://unity.com/legal/terms-of-service.). + +Unless expressly provided otherwise, the Software under this license is made available strictly on an "AS IS" BASIS WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED. Please review the license for details on these and other terms and conditions. \ No newline at end of file diff --git a/bulk_upload_cli/README.md b/bulk_upload_cli/README.md new file mode 100644 index 0000000..e3e7546 --- /dev/null +++ b/bulk_upload_cli/README.md @@ -0,0 +1,74 @@ +# Bulk upload CLI + +The Bulk upload Command-Line Interface (CLI) is a cross-platform command-line tool to connect to Asset Manager and execute administrative commands. It allows you to create configuration files that you can save and run from a terminal. Using CLI, you can create and update assets in bulk from your local disk to Asset Manager based on several inputs to match your folder structure. This tool offers an interactive mode where you are prompted to provide the necessary information to create and save configuration files for future asset updates. + +To connect and find support, join the [Help & Support page](https://cloud.unity.com/home/dashboard-support)! + +## Table of contents +- [Bulk upload CLI](#bulk-upload-cli) + - [Table of contents](#table-of-contents) + - [Prerequisites](#prerequisites) + - [System requirements](#system-requirements) + - [Licenses](#licenses) + - [How do I...?](#how-do-i) + - [Install the tool](#install-the-tool) + - [Run the tool in interactive mode](#run-the-tool-in-interactive-mode) + - [Select the input method](#select-the-input-method) + - [See also](#see-also) + - [Tell us what you think!](#tell-us-what-you-think) + +## Prerequisites + +### System requirements + +To run the script, you need: +- Blender 3.x installed on your machine +- An Asset Manager Contributor role on the project level or an Organization Owner role. For more information about roles, see [Roles and permissions](https://docs.unity.com/cloud/en-us/asset-manager/org-project-roles). You can upload up to 10 GB on the free tier of Unity Cloud. +- A Unity Cloud project with asset manager service enabled to upload assets. For more information on how to create a new project on Unity Cloud, see [Create a new project](https://docs.unity.com/cloud/en-us/asset-manager/new-asset-manager-project). +- An assigned seat if you are part of an entitled organization, that is, an organization with a Pro or Enterprise license. For more information, see the [Important notes](https://docs.unity.com/cloud/en-us/asset-manager/org-project-roles#project-level-roles) section. + +### Licenses + +The bulk download sample script is made available under the [Unity ToS license](./LICENSE.md). + +## How do I...? + +### Install the tool + +1. Navigate to the current folder with your terminal. +2. Run the following help command to install the tool: +* On Mac: `python3 bulk-cli.py --install` +* On Windows: `python bulk-cli.py --install` + +### Run the tool in interactive mode + +1. Navigate to the current folder with your terminal. +2. Run the following command: +* On Mac: `python3 bulk-cli.py --create` +* On Windows: `python bulk-cli.py --create` + +### Select the input method + +Select one of the three strategies as the input method for bulk asset creation: + +1. Answer the following CLI prompt: `Are you uploading assets from a Unity project?` +* Enter Yes if you upload files that are either: + * In a Unity project and have .meta files from the editor +or: + * In a Unity package, like content from the asset store + +2. If you answered No to the prompt in step 1, select either of the following under the `Select a strategy` prompt: + +* `group files by name`: Select this option if your assets are following a naming convention, for example, blueasset.fbx, blueasset.png. +![Using the group by name convention](./Documentation/group-by-name.png) + +* `group files by folder`: Select this option if your assets are organized by folder, that is, all relevant files are in distinct folders. +![Using the group by folder convention](./Documentation/group-by-folder.png) + +## See also + +- [Unity Cloud Python SDK documentation](https://docs.unity.com/cloud/en-us/asset-manager/python-sdk) + +## Tell us what you think! + +Thank you for taking a look at the project! To help us improve and provide greater value, please consider providing feedback in our [Help & Support page](https://cloud.unity.com/home/dashboard-support). Thank you! \ No newline at end of file diff --git a/bulk_upload_cli/bulk_assets_creation/__init__.py b/bulk_upload_cli/bulk_assets_creation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bulk_upload_cli/bulk_assets_creation/asset_gathering_strategies.py b/bulk_upload_cli/bulk_assets_creation/asset_gathering_strategies.py new file mode 100644 index 0000000..3e429ca --- /dev/null +++ b/bulk_upload_cli/bulk_assets_creation/asset_gathering_strategies.py @@ -0,0 +1,254 @@ +import os +import tarfile +import re +import shutil + +from abc import ABC, abstractmethod +from glob import glob +from pathlib import PurePath, PurePosixPath +from bulk_assets_creation.models import AssetInfo, FileInfo, ProjectUploaderConfig + + +def get_file_info(file: PurePath, root_folder: str) -> FileInfo: + return FileInfo(file, PurePosixPath(file.relative_to(root_folder))) + + +def get_meta_file(file: PurePath, root_folder: str) -> FileInfo: + return get_file_info(PurePath(f"{file}.meta"), root_folder) + + +def meta_file_exists(file: PurePath) -> bool: + return os.path.exists(f"{file}.meta") + + +class AssetGatheringStrategy(ABC): + @abstractmethod + def get_assets(self, config: ProjectUploaderConfig) -> [AssetInfo]: + pass + + @abstractmethod + def clean_up(self): + pass + + +class SingleFileUnityProjectStrategy(AssetGatheringStrategy): + + def get_assets(self, config: ProjectUploaderConfig) -> [AssetInfo]: + files = [] + + if len(config.file_extensions) == 0: + # If no file extensions are provided, we will use all files in the assets folder + files = [y for x in os.walk(config.assets_path) for y in glob(os.path.join(x[0], '*'))] + else: + for extension in config.file_extensions: + files.extend([y for x in os.walk(config.assets_path) for y in + glob(os.path.join(x[0], f'*.{extension}'))]) + + assets = dict() + for f in files: + if os.path.isdir(f): + continue + if f.endswith(".meta"): # meta file should not be considered as an asset alone + continue + + file_name = os.path.basename(f) + assets[file_name] = AssetInfo(file_name) + + file = get_file_info(PurePath(f), config.assets_path) + assets[file_name].files.append(file) + + if meta_file_exists(file.path): + meta_file = get_meta_file(file.path, config.assets_path) + assets[file_name].files.append(meta_file) + dependencies = [] + with open(file.path, 'r') as file_readable: + dependencies = get_dependencies_from_file(file_readable) + + with open(meta_file.path, 'r') as meta_file_readable: + meta_file_content = meta_file_readable.read() + assets[file_name].unity_id = get_unity_id_from_meta_file(meta_file_content) + dependencies.extend(get_dependencies_from_string(meta_file_content)) + + assets[file_name].dependencies.extend(list(set(dependencies))) + + return list(assets.values()) + + def clean_up(self): + remove_empty_file() + + +class NameGroupingStrategy(AssetGatheringStrategy): + + def get_assets(self, config: ProjectUploaderConfig) -> [AssetInfo]: + files = [] + + if len(config.file_extensions) == 0: + # If no file extensions are provided, we will use all files in the assets folder + files = [y for x in os.walk(config.assets_path) for y in glob(os.path.join(x[0], '*'))] + else: + for extension in config.file_extensions: + files.extend([y for x in os.walk(config.assets_path) for y in + glob(os.path.join(x[0], f'*.{extension}'))]) + + assets = dict() + for f in files: + if os.path.isdir(f): + continue + if f.endswith(".meta"): # meta file should not be considered as an asset alone + continue + + base_name = os.path.splitext(os.path.basename(f))[0] + if not config.case_sensitive: + base_name = base_name.lower() + + if base_name not in assets: + assets[base_name] = AssetInfo(base_name) + + file = get_file_info(PurePath(f), config.assets_path) + assets[base_name].files.append(file) + if meta_file_exists(file.path): + assets[base_name].files.append(get_meta_file(file.path, config.assets_path)) + + for common_file in config.files_common_to_every_assets: + common_file = PurePath(common_file) + for asset in assets.values(): + asset.files.append(get_file_info(common_file, config.assets_path)) + if meta_file_exists(common_file): + asset.files.append(get_meta_file(common_file, config.assets_path)) + + return list(assets.values()) + + def clean_up(self): + pass + + +class FolderGroupingStrategy(AssetGatheringStrategy): + + def get_assets(self, config: ProjectUploaderConfig) -> [AssetInfo]: + folders = [x[0] for x in os.walk(config.assets_path) if x[0] != config.assets_path] + + if len(folders) == 0: + print(f"No folders found in the assets path. Only the root folder will be considered as an asset") + folders = [config.assets_path] + + assets = dict() + for folder in folders: + asset_name = PurePath(folder).name + assets[asset_name] = AssetInfo(asset_name) + + if len(config.file_extensions) == 0: + # If no file extensions are provided, we will use all files in the folder + assets[asset_name].files = [get_file_info(PurePath(f), config.assets_path) for x in os.walk(folder) for f in glob(os.path.join(x[0], '*'))] + else: + for extension in config.file_extensions: + assets[asset_name].files.extend( + [get_file_info(PurePath(f), config.assets_path) for f in + glob(os.path.join(folder, f'*.{extension}'))]) + + for asset in assets.values(): + asset_files = asset.files.copy() + for file in asset_files: + if meta_file_exists(file.path) and get_meta_file(file.path, config.assets_path) not in asset.files: + asset.files.append(get_meta_file(file.path, config.assets_path)) + + return list(assets.values()) + + def clean_up(self): + pass + + +class UnityPackageStrategy(AssetGatheringStrategy): + + def get_assets(self, config: ProjectUploaderConfig) -> [AssetInfo]: + if config.assets_path == "": + print("No unity package path provided. Please provide a unityPackagePath in the config file. Exiting...") + return + + config.tags.append(PurePath(config.assets_path).name.replace(".unitypackage", "")) + + assets = [] + os.makedirs("tempo", exist_ok=True) + with tarfile.open(config.assets_path, 'r:gz') as tar: + tar_names = tar.getnames() + for name in tar_names: + member = tar.getmember(name) + if member.isdir(): + asset_file_name = name + "/asset" + meta_file_name = name + "/asset.meta" + path_file = name + "/pathname" + if asset_file_name in tar_names and meta_file_name in tar_names and path_file in tar_names: + tar.extract(asset_file_name, path="tempo") + tar.extract(meta_file_name, path="tempo") + dependencies = self.get_dependencies_from_tar_file(tar, asset_file_name) + dependencies.extend(self.get_dependencies_from_tar_file(tar, meta_file_name)) + + asset_path = self.get_path_from_pathname_file(tar, path_file) + asset = AssetInfo(asset_path.name) + asset.dependencies = list(set(dependencies)) + asset_file = PurePath("tempo").joinpath(asset_file_name) + asset.files.append(FileInfo(asset_file, PurePosixPath(asset_path.__str__()))) + meta_file = PurePath("tempo").joinpath(meta_file_name) + asset.files.append(FileInfo(meta_file, PurePosixPath(asset_path.__str__() + ".meta"))) + asset.unity_id = PurePath(name).as_posix() + + preview_file = name + "/preview.png" + if preview_file in tar_names: + tar.extract(preview_file, path="tempo") + asset.files.append(FileInfo(PurePosixPath(PurePath("tempo").joinpath(preview_file).__str__()), + PurePosixPath("preview.png"))) + asset.preview_file = PurePosixPath("preview.png") + + assets.append(asset) + + return assets + + def clean_up(self): + shutil.rmtree("tempo") + remove_empty_file() + + @staticmethod + def get_dependencies_from_tar_file(tar, file) -> list: + file = tar.extractfile(file) + return get_dependencies_from_file(file) + + @staticmethod + def get_path_from_pathname_file(tar, path_file): + file = tar.extractfile(path_file) + if file: + return PurePath(file.read().decode('utf-8')) + return None + + +def get_dependencies_from_file(file) -> list: + try: + file_content = file.read() + # If the file is a string, we can directly use it, otherwise we need to decode it + if isinstance(file_content, str): + return get_dependencies_from_string(file_content) + return get_dependencies_from_string(file_content.decode('utf-8')) + except UnicodeDecodeError: + pass # Ignore non-UTF-8 encoded files, they do not contain dependency information + return [] + + +def get_dependencies_from_string(file_content: str) -> list: + guid_regex = r"fileID:.*guid: ([a-f0-9]{32})" + pattern = re.compile(guid_regex) + return pattern.findall(file_content) + + +def get_unity_id_from_meta_file(meta_file_content) -> str: + guid_regex = r"\nguid: ([a-f0-9]{32})" + pattern = re.compile(guid_regex) + return pattern.findall(meta_file_content)[0] + + +def get_empty_file() -> PurePath: + if not os.path.exists("empty-file.template"): + with open("empty-file.template", "w") as file: + file.write("") + return PurePath("empty-file.template") + + +def remove_empty_file(): + os.remove("empty-file.template") \ No newline at end of file diff --git a/bulk_upload_cli/bulk_assets_creation/assets_uploader.py b/bulk_upload_cli/bulk_assets_creation/assets_uploader.py new file mode 100644 index 0000000..c002634 --- /dev/null +++ b/bulk_upload_cli/bulk_assets_creation/assets_uploader.py @@ -0,0 +1,263 @@ +import json +import os + +import unity_cloud as uc +from bulk_assets_creation.models import ProjectUploaderConfig, Strategy, FileInfo, AssetInfo +from bulk_assets_creation.asset_gathering_strategies import * +from concurrent.futures import ThreadPoolExecutor, wait +from unity_cloud.models import * +from pathlib import PurePath, PurePosixPath + + +def login_with_user_account(): + uc.identity.user_login.use() + auth_state = uc.identity.user_login.get_authentication_state() + if auth_state != uc.identity.user_login.Authentication_State.LOGGED_IN: + uc.identity.user_login.login() + + +class ProjectUploader: + + def __init__(self): + self.config = None + self.futures = list() + + @staticmethod + def login(key_id=None, key=None): + uc.initialize() + + if key is not None and key_id != "" and key_id is not None and key != "": + uc.identity.service_account.use(key_id, key) + else: + print("Logging in with user account in progress", flush=True) + login_with_user_account() + + @staticmethod + def get_collections(org_id: str, project_id: str): + collections = uc.assets.list_collections(org_id, project_id) + return [collection.name for collection in collections] + + def run(self, config: ProjectUploaderConfig, skip_login=False): + self.config = config + + if not skip_login: + self.login(config.key_id, config.key) + + self.validate_config() + + strategy = None + if self.config.strategy == Strategy.NAME_GROUPING: + strategy = NameGroupingStrategy() + elif self.config.strategy == Strategy.FOLDER_GROUPING: + strategy = FolderGroupingStrategy() + elif self.config.strategy == Strategy.UNITY_PACKAGE: + strategy = UnityPackageStrategy() + elif self.config.strategy == Strategy.SINGLE_FILE_ASSET: + strategy = SingleFileUnityProjectStrategy() + + print("Gathering assets", flush=True) + + project_assets = uc.assets.get_asset_list(self.config.org_id, self.config.project_id) + + assets = strategy.get_assets(self.config) + if assets is None: + return + + for asset in assets: + for project_asset in project_assets: + if asset.name == project_asset.name or (asset.name.lower() == project_asset.name.lower() and not self.config.case_sensitive): + asset.am_id = project_asset.id + asset.version = project_asset.version + asset.already_in_cloud = True + asset.is_frozen_in_cloud = project_asset.is_frozen + break + + with ThreadPoolExecutor(max_workers=self.config.amount_of_parallel_uploads) as executor: + for asset in assets: + if asset.am_id is None: + self.futures.append(executor.submit(self.create_asset, asset)) + elif asset.is_frozen_in_cloud: + self.futures.append(executor.submit(self.create_new_version, asset)) + + wait(self.futures) + self.futures = list() + + print("Setting asset dependencies", flush=True) + with ThreadPoolExecutor(max_workers=self.config.amount_of_parallel_uploads) as executor: + for asset in assets: + self.futures.append(executor.submit(self.set_asset_references, asset, assets)) + + wait(self.futures) + self.futures = list() + + with ThreadPoolExecutor(max_workers=self.config.amount_of_parallel_uploads) as executor: + for asset in assets: + if not asset.already_in_cloud or self.config.update_files: + self.futures.append(executor.submit(self.upload_asset_files, asset)) + else: + print(f"Skipping file upload for asset: {asset.name} because updateFiles is set to False", flush=True) + + wait(self.futures) + self.futures = list() + + print("Setting tags and collections for assets", flush=True) + with ThreadPoolExecutor(max_workers=self.config.amount_of_parallel_uploads) as executor: + for asset in assets: + self.futures.append(executor.submit(self.set_metadata_and_collection, asset)) + + wait(self.futures) + + strategy.clean_up() + print("Done uploading assets") + + def validate_config(self): + print("Validating configuration..", flush=True) + metadata_keys = uc.assets.list_field_definitions(self.config.org_id, self.config.project_id) + for key in self.config.metadata.keys(): + if key not in metadata_keys: + print("Key: " + key + " is not a valid metadata key. It will be ignored.") + self.config.metadata.pop(key) + + def create_asset(self, asset: AssetInfo): + try: + print(f"Creating asset: {asset.name} in cloud", flush=True) + asset_creation = AssetCreation(name=asset.name, type= AssetType.OTHER if len(asset.files) == 0 else self.get_asset_type(asset.files[0].cloud_path)) + created_asset = uc.assets.create_asset(asset_creation, self.config.org_id, self.config.project_id) + asset.am_id = created_asset.id + asset.version = created_asset.version + + except Exception as e: + print(f'Failed to create asset: {asset.name}', flush=True) + print(e, flush=True) + + def create_new_version(self, asset: AssetInfo): + try: + print(f"Creating new version for asset: {asset.name}", flush=True) + new_version = uc.assets.create_unfrozen_asset_version(self.config.org_id, self.config.project_id, asset.am_id, asset.version) + asset.version = new_version.version + except Exception as e: + print(f'Failed to create new version for asset: {asset.name}', flush=True) + print(e, flush=True) + + def upload_asset_files(self, asset: AssetInfo): + try: + + dataset_id = uc.assets.get_dataset_list(self.config.org_id, self.config.project_id, asset.am_id, asset.version)[0].id + + if self.config.update_files: + self.delete_existing_files(asset, dataset_id) + + print(f"Uploading files for asset: {asset.name}", flush=True) + files_upload_futures = [] + with ThreadPoolExecutor(max_workers=5) as executor: + for file in asset.files: + files_upload_futures.append(executor.submit(self.upload_file, asset, dataset_id, file)) + + wait(files_upload_futures) + + except Exception as e: + print(f'Failed to upload files for asset: {asset.name}', flush=True) + print(e, flush=True) + + def delete_existing_files(self, asset: AssetInfo, dataset_id: str): + file_list = uc.assets.get_file_list(self.config.org_id, self.config.project_id, asset.am_id, asset.version, dataset_id) + for file in file_list: + uc.assets.remove_file(self.config.org_id, self.config.project_id, asset.am_id, asset.version, dataset_id, file.path) + + def upload_file(self, asset: AssetInfo, dataset_id: str, file: FileInfo): + try: + if not self.config.update_files: + file_in_cloud = None + try: + file_in_cloud = uc.assets.get_file(self.config.org_id, self.config.project_id, asset.am_id, + asset.version, dataset_id, file.cloud_path) + except Exception: + # do nothing file was not found, this is expected + pass + + if file_in_cloud is not None: + print(f"File already in cloud: {file.cloud_path}", flush=True) + return + + file_upload = FileUploadInformation(organization_id=self.config.org_id, project_id=self.config.project_id, + asset_id=asset.am_id, asset_version=asset.version, dataset_id=dataset_id, + upload_file_path=file.path, cloud_file_path=file.cloud_path) + uc.assets.upload_file(file_upload, disable_automatic_transformations=True) + + except Exception as e: + print(f'Failed to upload file: {file.path}', flush=True) + print(e, flush=True) + + def set_asset_references(self, asset: AssetInfo, assets: [AssetInfo]): + try: + for dependency in asset.dependencies: + for a in assets: + if a.unity_id == dependency: + uc.assets.add_asset_reference(self.config.org_id, self.config.project_id, asset.am_id, asset.version, target_asset_id=a.am_id, target_asset_version=a.version) + asset.files.append( + FileInfo(get_empty_file(), PurePosixPath(f"{a.am_id}_{a.version}.am4u_dep"))) + break + + except Exception as e: + print(f'Failed to set references for asset: {asset.name}', flush=True) + print(e, flush=True) + + def set_metadata_and_collection(self, asset: AssetInfo): + + asset_update = AssetUpdate(name=asset.name) + + if len(self.config.tags) > 0: + asset_update.tags = self.config.tags + + if self.config.metadata is not None and len(self.config.metadata) > 0: + asset_update.metadata = self.config.metadata + + if self.config.description is not None and self.config.description != "": + asset_update.description = self.config.description + + try: + uc.assets.update_asset(asset_update, self.config.org_id, self.config.project_id, asset.am_id, asset.version) + except Exception as e: + print(f'Failed to update asset: {asset.name}', flush=True) + print(e, flush=True) + + if self.config.collection is not None and self.config.collection != "": + uc.assets.link_assets_to_collection(self.config.org_id, self.config.project_id, self.config.collection, + [asset.am_id]) + + uc.assets.freeze_asset_version(self.config.org_id, self.config.project_id, asset.am_id, asset.version, + "new version") + + def get_asset_type(self, cloud_path: PurePosixPath) -> AssetType: + suffix = cloud_path.suffix.lower() + if suffix in [".fbx", ".obj", ".prefab"]: + return AssetType.MODEL_3D + elif suffix in [".png", ".apng", ".jpg", ".jpeg", ".bmp", ".tiff", ".gif", ".psd", ".tga", ".tif", + ".exr", ".webp", ".svg", "pjpeg", ".pjp", ".jfif", ".avif", ".ico", ".cur", ".ani"]: + return AssetType.ASSET_2D + elif suffix in [".mp4", ".webm", ".ogg", ".ogv", ".avi", ".mov", ".flv", ".mkv", ".m4v", ".3gp", + ".h264", ".h265", "wmv"]: + return AssetType.VIDEO + elif suffix in [".mp3", ".wav", ".ogg", ".aac"]: + return AssetType.AUDIO + elif suffix == ".cs": + return AssetType.SCRIPT + elif suffix in [".mat", ".shader"]: + return AssetType.MATERIAL + else: + return AssetType.OTHER + + def get_file_info(self, file: PurePath) -> FileInfo: + return FileInfo(file, PurePosixPath(file.relative_to(self.config.assets_root_folder))) + + def get_meta_file(self, file: PurePath) -> FileInfo: + return self.get_file_info(PurePath(f"{file}.meta")) + + +if __name__ == '__main__': + config = ProjectUploaderConfig() + with open("config.json") as f: + config.load_from_json(json.load(f)) + + uploader = ProjectUploader() + uploader.run(config) \ No newline at end of file diff --git a/bulk_upload_cli/bulk_assets_creation/config.json b/bulk_upload_cli/bulk_assets_creation/config.json new file mode 100644 index 0000000..636e75e --- /dev/null +++ b/bulk_upload_cli/bulk_assets_creation/config.json @@ -0,0 +1,19 @@ +{ + "strategy": "", + "filesCommonToEveryAssets": [], + "assetsPath": "", + "assetFileExtensions": [], + "organizationId": "", + "projectId": "", + "serviceAccount": { + "keyId": "", + "key": "" + }, + "amountOfParallelUploads": 15, + "collectionToLinkAssetTo": "", + "tagsToApplyToAssets": [], + "assetNameCaseSensitive": false, + "metadataToApply": {}, + "updateFiles": true, + "description": "" +} \ No newline at end of file diff --git a/bulk_upload_cli/bulk_assets_creation/interactive_runner.py b/bulk_upload_cli/bulk_assets_creation/interactive_runner.py new file mode 100644 index 0000000..6eaa704 --- /dev/null +++ b/bulk_upload_cli/bulk_assets_creation/interactive_runner.py @@ -0,0 +1,331 @@ +import json +import os + +from InquirerPy import prompt +from InquirerPy.base.control import Choice + +from bulk_assets_creation.models import ProjectUploaderConfig, Strategy +from bulk_assets_creation.assets_uploader import ProjectUploader + +project_uploader = ProjectUploader() + + +def run(write_config: bool = False): + project_uploader = ProjectUploader() + key_id, key = ask_for_login() + project_uploader.login(key_id, key) + + questions = [ + { + "type": "confirm", + "message": "Are you uploading assets from a Unity project?", + "default": False + } + ] + + result = prompt(questions=questions) + if result[0]: + ask_unity_project_questions(write_config=write_config) + else: + ask_non_unity_project_questions(write_config=write_config) + + +def ask_unity_project_questions(write_config: bool = False): + questions = [ + { + "type": "list", + "message": "Where are the assets located?", + "choices": ["in a .unitypackage file", "in a folder", Choice(value=None, name="Exit")], + "default": 0, + } + ] + result = prompt(questions=questions) + + if result[0] == "in a .unitypackage file": + run_unity_package_strategy(write_config) + elif result[0] == "in a folder": + run_non_packaged_strategy(Strategy.SINGLE_FILE_ASSET, write_config) + + +def ask_non_unity_project_questions(write_config: bool = False): + questions = [ + { + "type": "list", + "message": "Select a strategy:", + "choices": ["group files by name", "group files by folder", Choice(value=None, name="Exit")], + "default": 0, + } + ] + result = prompt(questions=questions) + + if result[0] == "group files by name": + run_non_packaged_strategy(Strategy.NAME_GROUPING, write_config) + elif result[0] == "group files by folder": + run_non_packaged_strategy(Strategy.FOLDER_GROUPING, write_config) + + +def run_non_packaged_strategy(strategy: Strategy, write_config: bool = False): + config = ProjectUploaderConfig() + config.strategy = strategy + + questions = [ + { + "type": "input", + "message": "Enter the path to the root folder of the assets:", + "default": "" + }, + { + "type": "input", + "message": "Enter your organization ID:", + "default": "" + }, + { + "type": "input", + "message": "Enter your project ID:", + "default": "" + }, + { + "type": "confirm", + "message": "Would you like to update existing assets?", + "default": True + }, + { + "type": "input", + "message": "Enter the tags to apply to the assets (comma separated; leave empty to assign no tag):", + } + ] + + if strategy == Strategy.NAME_GROUPING or strategy == Strategy.SINGLE_FILE_ASSET: + questions.append({ + "type": "input", + "message": "Enter the file extensions to include (comma separated; leave empty to include everything in the search):", + "default": "" + }) + + if strategy == Strategy.NAME_GROUPING: + questions.append({ + "type": "confirm", + "message": "Is the asset name case sensitive?", + "default": False + }) + questions.append({ + "type": "input", + "message": "Enter the files that are common to every asset (comma separated; leave empty if there are none):", + "default": "" + }) + + result = prompt(questions=questions) + + config.assets_path = sanitize_string(result[0]) + config.org_id = result[1] + config.project_id = result[2] + config.update_files = result[3] + + config.tags = sanitize_tags(result[4]) + + if strategy == Strategy.NAME_GROUPING or strategy == Strategy.SINGLE_FILE_ASSET: + config.file_extensions = result[5].split(",") if result[5] != "" else [] + + if strategy == Strategy.NAME_GROUPING: + config.case_sensitive = result[6] + config.files_common_to_every_assets = result[7].split(",") if result[7] != "" else [] + + try: + collections = project_uploader.get_collections(config.org_id, config.project_id) + except Exception as e: + collections = [] + + collections.append(Choice(value=None, name="No collection")) + + questions = [ + { + "type": "list", + "message": "Select the collection you want to link the assets to.", + "choices": collections, + "default": 0, + }] + + result = prompt(questions=questions) + config.collection = result[0] if result[0] != "No collection" else "" + + if write_config: + write_config_file(config) + else: + questions = [{ + "type": "confirm", + "message": "Would you like to save the config of this run?", + "default": False + }] + + result = prompt(questions=questions) + + if result[0]: + write_config_file(config) + + project_uploader.run(config, True) + + +def run_unity_package_strategy(write_config: bool = False): + questions = [ + { + "type": "input", + "message": "Enter the path to the Unity package:", + "default": "" + }, + { + "type": "input", + "message": "Enter your organization ID:", + "default": "" + }, + { + "type": "input", + "message": "Enter your project ID:", + "default": "" + }, + { + "type": "confirm", + "message": "Would you like to update existing assets?", + "default": True + }, + { + "type": "input", + "message": "Enter the tags to apply to the assets (comma separated; leave empty to assign no tag):", + } + ] + + result = prompt(questions=questions) + + config = ProjectUploaderConfig() + config.strategy = Strategy.UNITY_PACKAGE + config.assets_path = sanitize_string(result[0]) + config.org_id = result[1] + config.project_id = result[2] + config.update_files = result[3] + config.tags = sanitize_tags(result[4]) + + try: + collections = project_uploader.get_collections(config.org_id, config.project_id) + except Exception as e: + collections = [] + + collections.append(Choice(value=None, name="No collection")) + + questions = [ + { + "type": "list", + "message": "Select the collection you want to link the assets to.", + "choices": collections, + "default": 0, + }] + + result = prompt(questions=questions) + config.collection = result[0] if result[0] != "No collection" else "" + + + if write_config: + write_config_file(config) + else: + questions = [{ + "type": "confirm", + "message": "Would you like to save the config of this run?", + "default": False + }] + + result = prompt(questions=questions) + + if result[0]: + write_config_file(config) + + project_uploader.run(config, True) + + +def ask_for_login(): + questions = [{ + "type": "list", + "message": "Choose authentication method?", + "choices": ["User login", "Service account"], + "default": 0, + }] + + result = prompt(questions=questions) + + if result[0] == "Service account": + questions = [ + { + "type": "input", + "message": "Enter your key ID:", + "default": "" + }, + { + "type": "password", + "message": "Enter your key:", + "default": "" + } + ] + + result = prompt(questions=questions) + + return result[0], result[1] + + return "", "" + + +def write_config_file(config: ProjectUploaderConfig): + questions = [ + { + "type": "input", + "message": "Enter the name to save the configuration file:", + "default": "" + } + ] + result = prompt(questions=questions) + file_name = result[0] if result[0].endswith(".json") else result[0] + ".json" + with open(file_name, "w") as f: + f.write(config.to_json()) + print("Configuration saved to", file_name) + + +def run_with_config_select(): + config_files = [f for f in os.listdir() if f.endswith(".json")] + if len(config_files) == 0: + print("No configuration files found in the current directory. Please create a configuration file first.") + return + + questions = [ + { + "type": "list", + "message": "Select a configuration file:", + "choices": config_files, + "default": 0 + } + ] + + result = prompt(questions=questions) + with open(result[0], "r") as f: + config = ProjectUploaderConfig() + config.load_from_json(json.load(f)) + project_uploader.run(config, False) + + +def sanitize_tags(tags: str) -> list[str]: + + tags = [tag for tag in tags.split(",") if tag != ""] + return_tags = [] + for tag in tags: + if tag == "": + continue + + tag = sanitize_string(tag) + return_tags.append(tag) + + return return_tags + + +def sanitize_string(value: str) -> str: + while value.startswith(" ") or value.endswith(" "): + if value.startswith(" "): + value = value[1:] + if value.endswith(" "): + value = value[:-1] + + return value \ No newline at end of file diff --git a/bulk_upload_cli/bulk_assets_creation/models.py b/bulk_upload_cli/bulk_assets_creation/models.py new file mode 100644 index 0000000..7d34dd3 --- /dev/null +++ b/bulk_upload_cli/bulk_assets_creation/models.py @@ -0,0 +1,100 @@ +import json +from enum import Enum +from pathlib import PurePath, PurePosixPath + + +class Strategy(str, Enum): + SINGLE_FILE_ASSET = "singleFileAsset" + NAME_GROUPING = "nameGrouping" + FOLDER_GROUPING = "folderGrouping" + UNITY_PACKAGE = "unityPackage" + + +class ProjectUploaderConfig(object): + + def __init__(self): + self.strategy = Strategy.SINGLE_FILE_ASSET + self.files_common_to_every_assets = [] + self.assets_path = "" + self.file_extensions = [] + self.org_id = "" + self.project_id = "" + self.key_id = "" + self.key = "" + self.amount_of_parallel_uploads = 15 + self.collection = "" + self.tags = [] + self.case_sensitive = False + self.metadata = {} + self.update_files = False + self.description = "" + + def load_from_json(self, config_json: dict): + self.strategy = Strategy(config_json.get("strategy", "resource_type")) + self.files_common_to_every_assets = config_json.get("filesCommonToEveryAssets", []) + self.assets_path = config_json.get("assetsPath", "") + self.file_extensions = config_json.get("assetFileExtensions", []) + self.org_id = config_json.get("organizationId", "") + self.project_id = config_json.get("projectId", "") + self.key_id = config_json.get("serviceAccount", dict()).get("keyId", None) + self.key = config_json.get("serviceAccount", dict()).get("key", None) + self.amount_of_parallel_uploads = config_json.get("amountOfParallelUploads", 15) + self.collection = config_json.get("collectionToLinkAssetTo", None) + self.tags = config_json.get("tagsToApplyToAssets", []) + self.case_sensitive = config_json.get("assetNameCaseSensitive", False) + self.metadata = config_json.get("metadataToApply", {}) + self.update_files = config_json.get("updateFiles", False) + self.description = config_json.get("description", "") + + # remove the "." from the file extensions + self.file_extensions = [x[1:] if x.startswith(".") else x for x in self.file_extensions] + + # we remove meta file extension since they are included by default + if "meta" in self.file_extensions: + self.file_extensions.remove("meta") + if ".meta" in self.file_extensions: + self.file_extensions.remove(".meta") + + def to_json(self): + + asset_path = json.dumps(self.assets_path) + files_common_to_every_assets = [json.dumps(x) for x in self.files_common_to_every_assets] + return rf"""{{ + "strategy": "{self.strategy.value}", + "filesCommonToEveryAssets": {json.dumps(files_common_to_every_assets)}, + "assetsPath": {json.dumps(self.assets_path)}, + "assetFileExtensions": {json.dumps(self.file_extensions)}, + "organizationId": "{self.org_id}", + "projectId": "{self.project_id}", + "serviceAccount": {{ + "keyId": "{self.key_id}", + "key": "{self.key}" + }}, + "amountOfParallelUploads": {self.amount_of_parallel_uploads}, + "collectionToLinkAssetTo": "{self.collection}", + "tagsToApplyToAssets": {json.dumps(self.tags)}, + "assetNameCaseSensitive": {self.case_sensitive.__str__().lower()}, + "metadataToApply": {self.metadata}, + "updateFiles": {self.update_files.__str__().lower()}, + "description": {json.dumps(self.description)} +}} +""" + + +class FileInfo(object): + def __init__(self, path: PurePath, cloud_path: PurePosixPath): + self.path = path + self.cloud_path = cloud_path + + +class AssetInfo(object): + def __init__(self, name): + self.name = name + self.files = [] + self.am_id = None + self.unity_id = None + self.dependencies = [] + self.already_in_cloud = False + self.version = "" + self.preview_file = None + self.is_frozen_in_cloud = False \ No newline at end of file diff --git a/bulk_upload_cli/bulk_cli.py b/bulk_upload_cli/bulk_cli.py new file mode 100644 index 0000000..dafe448 --- /dev/null +++ b/bulk_upload_cli/bulk_cli.py @@ -0,0 +1,103 @@ +import argparse +import platform +import json +from shared.utils import OperationSystem, download_wheel, pip_install_wheel, pip_install_other_libraries, \ + check_install_requirements, check_python_version +import os + +source_folder = "../Source" +wheels_path = os.curdir + "/wheels" + + +def read_arguments(): + parser = argparse.ArgumentParser() + parser.add_argument("--install", action="store_true", help="Install the requirements for the tool") + parser.add_argument("--create", action="store_true", help="Bulk create assets in the cloud") + parser.add_argument("--config-write", action="store_true", + help="Write the configuration file instead of running the action. Use with --create.", default=False) + parser.add_argument("--config-select", action="store_true",help="Select a configuration file to run. Use with --create.", default=False) + parser.add_argument("--config", type=str, help="Path to the configuration file. Use with --create.", default=None) + + args = parser.parse_args() + return args + + +def get_current_os(): + system = platform.system() + if system == "Windows": + return OperationSystem.windows + elif system == "Linux": + return "linux" + elif system == "Darwin": # macOS + return OperationSystem.macos + else: + raise Exception("Unsupported operating system:" + system) + + +def install_requirements(): + current_os = get_current_os() + download_wheel(wheels_path, current_os, False) + pip_install_wheel(wheels_path, current_os) + pip_install_other_libraries() + + +def run_bulk_assets_creation(interactive=False, config=None, write_config=False, config_select=False): + + if config_select: + from bulk_assets_creation import interactive_runner + interactive_runner.run_with_config_select() + elif interactive or write_config: + from bulk_assets_creation import interactive_runner + interactive_runner.run(write_config=write_config) + else: + if config is None: + raise Exception("Configuration file must be provided when running in non-interactive mode.") + from bulk_assets_creation import models, assets_uploader + creation_config = models.ProjectUploaderConfig() + with open(config, "r") as f: + creation_config.load_from_json(json.load(f)) + uploader = assets_uploader.ProjectUploader() + uploader.run(creation_config) + + +if __name__ == "__main__": + arguments = read_arguments() + + config = arguments.config + write_config = arguments.config_write + config_select = arguments.config_select + interactive = False + + if not check_python_version(): + print("Python version is not supported. Please use Python 3.9 or higher.") + exit(1) + + if arguments.install: + install_requirements() + print("\n\n\n") + print("===============================================") + print("Requirements installed.") + exit(0) + + if not check_install_requirements(): + print("It seems that the requirements are not installed. Please run the script with --install first") + exit(1) + + if not arguments.create and not arguments.install: + print("No action specified. Please always use --create.") + exit(1) + + if config is None and not write_config and not config_select: + print("No config options provided. Interactive mode will be used.") + interactive = True + + if config is not None and write_config: + raise Exception("Both --config and --write-config cannot be used at the same time.") + + if config is not None and not os.path.exists(config): + raise Exception("Configuration file not found.") + + if arguments.create: + run_bulk_assets_creation(interactive, config, write_config, config_select) + else: + print("No action specified. Please always use --create.") \ No newline at end of file diff --git a/bulk_upload_cli/shared/__init__.py b/bulk_upload_cli/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/bulk_upload_cli/shared/utils.py b/bulk_upload_cli/shared/utils.py new file mode 100644 index 0000000..de6e8e3 --- /dev/null +++ b/bulk_upload_cli/shared/utils.py @@ -0,0 +1,192 @@ +import os.path +import shutil +import sys +import urllib.request +import re +import glob +import subprocess +from enum import Enum + + +class OperationSystem(Enum): + windows = 'windows' + macos = 'macos' + + +def get_platform_name(system: str, machine: str) -> str: + name: str + if system == "windows": + if machine == "amd64" or machine == "x86_64": + name = "win_amd64" + elif machine == "arm64": + name = "win_arm64" + elif system == "darwin": + name = "macosx_13_0_universal2" + else: + raise Exception(f"Unsupported configuration: {system}-{machine}") + return name + + +sdk_version = "0.9.2" +protocol = "https://" +domain = "transformation.unity.com" +url_format = f"{protocol}{domain}/downloads/pythonsdks/release/{sdk_version}/unity_cloud-{sdk_version}-py3-none-{{0}}.whl" + +wheel_names = { + OperationSystem.macos: f"unity_cloud-{sdk_version}-py3-none-macosx_13_0_universal2.whl", + OperationSystem.windows: f"unity_cloud-{sdk_version}-py3-none-win_amd64.whl", +} + +operation_systems = { + OperationSystem.macos: url_format.format("macosx_13_0_universal2"), + OperationSystem.windows: url_format.format("win_amd64") +} + + +colors = { + "reset": "\x1b[0m", + "red": "\x1b[31m", + "green": "\x1b[32m", + "yellow": "\x1b[33m", + "cyan": "\x1b[36m", +} + + +def __log(color: str, msg: str): + print(f"{__c(color, msg)}") + + +def __is_windows(): + return sys.platform == "win32" and os.name == "nt" + + +def __c(color: str, msg: str) -> str: + if __is_windows(): + return msg + else: + return colors[color] + msg + colors["reset"] + + +def log_ok(msg: str): + __log("green", msg) + + +def log_warning(msg: str): + __log("yellow", f"WARNING: {msg}") + + +def log_error(msg: str): + __log("red", f"ERROR: {msg}") + + +def copy_wheels(source_folder: str, destination_folder: str, systems: list[OperationSystem], + skip_missing: bool) -> bool: + if not os.path.exists(source_folder): + return False + else: + os.makedirs(destination_folder, exist_ok=True) + for system in systems: + wheel_details = operation_systems[system] + for platform_name in wheel_details: + matching_files = glob.glob(f"{source_folder}/unity_cloud*-py3-none-{platform_name}.whl") + if len(matching_files) == 0: + msg = f"Could not find wheel file for {platform_name}" + if skip_missing: + log_warning(msg) + else: + log_error(msg) + return False + for source_file in matching_files: + destination_file = os.path.join(destination_folder, os.path.basename(source_file)) + if source_file != destination_file: + shutil.copy(source_file, destination_file) + print(f"\"{source_file}\" copied to \"{destination_file}\"") + return True + + +def __download_file(download_path: str, file_url: str) -> bool: + try: + response = urllib.request.urlopen(file_url) + except Exception as err: + log_error(f"Failed to download from {file_url}. Exception: {err}") + return False + + if response.code != 200: + log_error(f"Failed to download from {file_url}. Status code: {response.code}") + return False + + decoded_filename: str + if 'Content-Disposition' in response.headers: + content_disposition = response.headers['Content-Disposition'] + filename_match = re.search(r'filename\*=(?:UTF-8\'\'|utf-8\'\'|\'\'|"")?([^\'"]+)', content_disposition) + + if not filename_match: + log_error(f"Failed to download from {file_url}: Downloaded data has unexpected format") + return False + + utf8_encoded_filename = filename_match.group(1) + decoded_filename = utf8_encoded_filename + else: + decoded_filename = file_name = os.path.basename(file_url) + with open(os.path.join(download_path, decoded_filename), "wb") as file: + file.write(response.read()) + return True + + +def download_wheel(download_path: str, system: str, skip_missing: bool, + overwrite=False, write_log=True, ) -> bool: + os.makedirs(download_path, exist_ok=True) + if write_log: + print("Downloading unity-cloud wheel files...") + + wheel_name = operation_systems[system] + path = os.path.join(download_path, wheel_name) + if not os.path.exists(path) or overwrite: + if write_log: + print(f"Downloading wheel file for {wheel_name}...") + __download_file(download_path, wheel_name) + + else: + print(f"Skipping \"{path}\". The file already exists") + return True + + +def pip_install_wheel(download_path: str, system: str): + install_command = [sys.executable, "-m", "pip", "install", "wheel"] + try: + subprocess.run(install_command, check=True) + except subprocess.CalledProcessError: + sys.stderr.write(f"Failed to install wheel package\n") + return False + + wheel_name = wheel_names[system] + wheel_path = os.path.join(download_path, wheel_name) + install_command = [sys.executable, "-m", "pip", "install", wheel_path, "--force-reinstall"] + try: + subprocess.run(install_command, check=True) + print(f"Wheel {wheel_path} installed successfully.") + except subprocess.CalledProcessError: + sys.stderr.write(f"Failed to install wheel {wheel_path}\n") + + +def pip_install_other_libraries(): + install_command = [sys.executable, "-m", "pip", "install", "InquirerPy"] + try: + subprocess.run(install_command, check=True) + print(f"Other libraries installed successfully.") + except subprocess.CalledProcessError: + sys.stderr.write(f"Failed to install other libraries\n") + +def check_python_version(): + if sys.version_info < (3, 10): + return False + return True + +def check_install_requirements(): + try: + from InquirerPy import prompt + from InquirerPy.base.control import Choice + import unity_cloud + except ImportError: + return False + return True \ No newline at end of file diff --git a/catalog-info.yaml b/catalog-info.yaml index 3d15ed6..f2a79e8 100644 --- a/catalog-info.yaml +++ b/catalog-info.yaml @@ -3,14 +3,11 @@ apiVersion: backstage.io/v1alpha1 kind: Component metadata: annotations: - github.com/project-slug: Unity-Technologies/unity-cloud-python-sdk-samples - name: unity-cloud-python-sdk-samples + github.com/project-slug: unity/python-sdk-samples + name: python-sdk-samples description: "Various samples that show how to use the Unity Cloud Python SDK." labels: costcenter: "7054" - tags: - - planned-public - - enterprise links: - url: https://unity.slack.com/messages/C04R01SGG68/ title: "#uc-cs-am-python-sdk" @@ -18,4 +15,4 @@ metadata: spec: type: other lifecycle: experimental - owner: Unity-Technologies/unity-cloud-sdk-admin + owner: unity/digital-twins-sdk-admins diff --git a/sample_bulk_actions/.idea/workspace.xml b/sample_bulk_actions/.idea/workspace.xml new file mode 100644 index 0000000..edc93bd --- /dev/null +++ b/sample_bulk_actions/.idea/workspace.xml @@ -0,0 +1,86 @@ + + + + + + + + + + + + { + "associatedIndex": 0 +} + + + + { + "keyToString": { + "RunOnceActivity.OpenProjectViewOnStart": "true", + "RunOnceActivity.ShowReadmeOnStart": "true", + "WebServerToolWindowFactoryState": "false", + "last_opened_file_path": "C:/UnitySrc/python-sdk-samples/sample_bulk_actions/bulk_assets_creation", + "node.js.detected.package.eslint": "true", + "node.js.detected.package.tslint": "true", + "node.js.selected.package.eslint": "(autodetect)", + "node.js.selected.package.tslint": "(autodetect)", + "nodejs_package_manager_path": "npm", + "vue.rearranger.settings.migration": "true" + } +} + + + + + + + + + + + + + 1722278783914 + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..6d75f77 --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1 @@ +sonar.projectKey=unity_python-sdk-samples_6a692b1e-5f4d-40e0-a30f-b1286b3f4de8 \ No newline at end of file