├── media ├── ERD.png ├── MAGLA.pdf ├── diagram.png ├── flask.png ├── magla.png ├── react.png ├── magla_avatar.png ├── magla_banner.png ├── magla_logo.png └── branch_diagram.png ├── tests ├── UTF-8-test.txt ├── magla_machine │ └── machine.ini ├── table_names.yml ├── conftest.py ├── phase_2 │ └── test_root.py ├── phase_1 │ ├── test_tool_version_installation.py │ ├── test_facility.py │ ├── test_assignment.py │ ├── test_tool.py │ ├── test_timeline.py │ ├── test_tool_version.py │ ├── test_settings2d.py │ ├── test_shot_version.py │ ├── test_utils.py │ ├── test_machine.py │ ├── test_tool_config.py │ ├── test_user.py │ ├── test_shot.py │ └── test_project.py ├── test_project.otio └── seed_data.yaml ├── magla ├── db │ ├── dependency.py │ ├── tool.py │ ├── facility.py │ ├── file_type.py │ ├── timeline.py │ ├── assignment.py │ ├── tool_version_installation.py │ ├── settings_2d.py │ ├── machine.py │ ├── context.py │ ├── user.py │ ├── shot_version.py │ ├── directory.py │ ├── episode.py │ ├── __init__.py │ ├── tool_version.py │ ├── sequence.py │ ├── tool_config.py │ ├── project.py │ ├── shot.py │ └── orm.py ├── core │ ├── errors.py │ ├── __init__.py │ ├── episode.py │ ├── sequence.py │ ├── facility.py │ ├── file_type.py │ ├── dependency.py │ ├── assignment.py │ ├── settings_2d.py │ ├── tool_version_installation.py │ ├── machine.py │ ├── context.py │ ├── tool_version.py │ ├── shot_version.py │ ├── user.py │ ├── config.py │ ├── entity.py │ ├── tool_config.py │ ├── tool.py │ ├── timeline.py │ ├── shot.py │ ├── directory.py │ └── data.py ├── __init__.py ├── test.py └── utils.py ├── setup.py ├── test_project.json ├── .gitignore ├── .github └── workflows │ └── magla_ci.yml └── example.py /media/ERD.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/media/ERD.png -------------------------------------------------------------------------------- /media/MAGLA.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/media/MAGLA.pdf -------------------------------------------------------------------------------- /media/diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/media/diagram.png -------------------------------------------------------------------------------- /media/flask.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/media/flask.png -------------------------------------------------------------------------------- /media/magla.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/media/magla.png -------------------------------------------------------------------------------- /media/react.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/media/react.png -------------------------------------------------------------------------------- /media/magla_avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/media/magla_avatar.png -------------------------------------------------------------------------------- /media/magla_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/media/magla_banner.png -------------------------------------------------------------------------------- /media/magla_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/media/magla_logo.png -------------------------------------------------------------------------------- /tests/UTF-8-test.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/tests/UTF-8-test.txt -------------------------------------------------------------------------------- /tests/magla_machine/machine.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | uuid = 00000000-0000-0000-0000-7946f8ba868d 3 | 4 | -------------------------------------------------------------------------------- /media/branch_diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/magnetic-lab/magla/HEAD/media/branch_diagram.png -------------------------------------------------------------------------------- /tests/table_names.yml: -------------------------------------------------------------------------------- 1 | - assignments 2 | - contexts 3 | - dependencies 4 | - directories 5 | - facilities 6 | - file_types 7 | - machines 8 | - projects 9 | - settings_2d 10 | - shot_versions 11 | - shots 12 | - timelines 13 | - tool_configs 14 | - tool_version_installations 15 | - tool_versions 16 | - tools 17 | - users -------------------------------------------------------------------------------- /magla/db/dependency.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from sqlalchemy.dialects.postgresql import JSON 3 | 4 | from ..db.orm import MaglaORM 5 | 6 | 7 | class Dependency(MaglaORM._Base): 8 | __tablename__ = "dependencies" 9 | __table_args__ = {'extend_existing': True} 10 | __entity_name__ = "Dependency" 11 | 12 | id = Column(Integer, primary_key=True) 13 | entity_type = Column(String) 14 | package = Column(JSON) 15 | -------------------------------------------------------------------------------- /magla/db/tool.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from sqlalchemy.dialects.postgresql import JSON 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class Tool(MaglaORM._Base): 9 | __tablename__ = "tools" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "Tool" 12 | 13 | id = Column(Integer, primary_key=True) 14 | name = Column(String) 15 | description = Column(String) 16 | 17 | versions = relationship("ToolVersion", back_populates="tool") 18 | -------------------------------------------------------------------------------- /magla/db/facility.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from sqlalchemy.dialects.postgresql import JSON 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class Facility(MaglaORM._Base): 9 | __tablename__ = "facilities" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "Facility" 12 | 13 | id = Column(Integer, primary_key=True) 14 | name = Column(String) 15 | settings = Column(JSON) 16 | 17 | machines = relationship("Machine", back_populates="facility") 18 | -------------------------------------------------------------------------------- /magla/db/file_type.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from sqlalchemy.dialects.postgresql import JSON 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class FileType(MaglaORM._Base): 9 | __tablename__ = "file_types" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "FileType" 12 | 13 | id = Column(Integer, primary_key=True) 14 | name = Column(String(20)) 15 | extensions = Column(JSON) 16 | description = Column(String(100)) 17 | 18 | tool_versions = relationship("ToolVersion", back_populates="file_types") 19 | -------------------------------------------------------------------------------- /magla/db/timeline.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String 2 | from sqlalchemy.dialects.postgresql import JSON 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class Timeline(MaglaORM._Base): 9 | __tablename__ = "timelines" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "Timeline" 12 | 13 | id = Column(Integer, primary_key=True) 14 | user_id = Column(Integer, ForeignKey("users.id")) 15 | label = Column(String) 16 | otio = Column(JSON) 17 | 18 | user = relationship("User", uselist=False, back_populates="timelines") 19 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | """Root testing fixture for creating and serving mock database session.""" 2 | import os 3 | os.environ["MAGLA_DB_NAME"] = "magla_testing" 4 | 5 | import pytest 6 | 7 | from magla.test import MaglaEntityTestFixture 8 | 9 | @pytest.fixture(scope='session') 10 | def entity_test_fixture(): 11 | entity_test_fixture_ = MaglaEntityTestFixture() 12 | # create and start a testing session with backend 13 | entity_test_fixture_.start() 14 | entity_test_fixture_.create_all_seed_records() 15 | yield entity_test_fixture_ 16 | # end testing session and drop all tables 17 | entity_test_fixture_.end(drop_tables=True) 18 | 19 | -------------------------------------------------------------------------------- /magla/db/assignment.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String 2 | from sqlalchemy.orm import relationship 3 | 4 | from ..db.orm import MaglaORM 5 | 6 | 7 | class Assignment(MaglaORM._Base): 8 | __tablename__ = "assignments" 9 | __table_args__ = {'extend_existing': True} 10 | __entity_name__ = "Assignment" 11 | 12 | id = Column(Integer, primary_key=True) 13 | user_id = Column(Integer, ForeignKey("users.id")) 14 | shot_version_id = Column(Integer, ForeignKey("shot_versions.id")) 15 | 16 | shot_version = relationship("ShotVersion") 17 | user = relationship("User", uselist=False, back_populates="assignments") 18 | -------------------------------------------------------------------------------- /magla/db/tool_version_installation.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer 2 | from sqlalchemy.orm import relationship 3 | 4 | from ..db.orm import MaglaORM 5 | 6 | 7 | class ToolVersionInstallation(MaglaORM._Base): 8 | __tablename__ = "tool_version_installations" 9 | __table_args__ = {'extend_existing': True} 10 | __entity_name__ = "ToolVersionInstallation" 11 | 12 | id = Column(Integer, primary_key=True) 13 | tool_version_id = Column(Integer, ForeignKey("tool_versions.id")) 14 | directory_id = Column(Integer, ForeignKey("directories.id")) 15 | 16 | tool_version = relationship("ToolVersion", uselist=False, back_populates="installations") 17 | directory = relationship("Directory") 18 | -------------------------------------------------------------------------------- /magla/db/settings_2d.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Float, Integer, String 2 | from sqlalchemy.orm import relationship 3 | from sqlalchemy.sql.schema import ForeignKey 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class Settings2D(MaglaORM._Base): 9 | __tablename__ = "settings_2d" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "Settings2D" 12 | 13 | id = Column(Integer, primary_key=True) 14 | project_id = Column(Integer, ForeignKey("projects.id")) 15 | label = Column(String) 16 | width = Column(Integer) 17 | height = Column(Integer) 18 | rate = Column(Float) 19 | color_profile = Column(String) 20 | 21 | project = relationship("Project", uselist=False, back_populates="settings_2d") 22 | -------------------------------------------------------------------------------- /magla/db/machine.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String 2 | from sqlalchemy.orm import relationship 3 | 4 | from ..db.orm import MaglaORM 5 | 6 | 7 | class Machine(MaglaORM._Base): 8 | __tablename__ = "machines" 9 | __table_args__ = {'extend_existing': True} 10 | __entity_name__ = "Machine" 11 | 12 | id = Column(Integer, primary_key=True) 13 | facility_id = Column(Integer, ForeignKey("facilities.id")) 14 | uuid = Column(String) # unique 15 | name = Column(String) 16 | ip_address = Column(String) 17 | 18 | facility = relationship("Facility", back_populates="machines") 19 | contexts = relationship("Context", back_populates="machine") 20 | directories = relationship("Directory", back_populates="machine") 21 | -------------------------------------------------------------------------------- /magla/db/context.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer 2 | from sqlalchemy.orm import relationship 3 | 4 | from ..db.orm import MaglaORM 5 | from .user import User 6 | 7 | 8 | class Context(MaglaORM._Base): 9 | __tablename__ = "contexts" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "Context" 12 | 13 | id = Column(Integer, ForeignKey(User.id), primary_key=True) 14 | machine_id = Column(Integer, ForeignKey("machines.id")) 15 | assignment_id = Column(Integer, ForeignKey("assignments.id")) 16 | 17 | machine = relationship("Machine", uselist=False, back_populates="contexts") 18 | user = relationship("User", uselist=False, back_populates="context") 19 | assignment = relationship("Assignment", uselist=False) 20 | -------------------------------------------------------------------------------- /magla/db/user.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from sqlalchemy.orm import relationship 3 | 4 | from ..db.orm import MaglaORM 5 | 6 | 7 | class User(MaglaORM._Base): 8 | __tablename__ = 'users' 9 | __table_args__ = {'extend_existing': True} 10 | __entity_name__ = "User" 11 | 12 | id = Column(Integer, primary_key=True) 13 | 14 | first_name = Column(String) 15 | last_name = Column(String) 16 | nickname = Column(String) 17 | email = Column(String) 18 | 19 | context = relationship("Context", uselist=False, back_populates="user") 20 | assignments = relationship("Assignment", back_populates="user") 21 | directories = relationship("Directory", back_populates="user") 22 | timelines = relationship("Timeline", back_populates="user") 23 | -------------------------------------------------------------------------------- /magla/db/shot_version.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer 2 | from sqlalchemy.dialects.postgresql import JSON 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class ShotVersion(MaglaORM._Base): 9 | __tablename__ = "shot_versions" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "ShotVersion" 12 | 13 | id = Column(Integer, primary_key=True) 14 | shot_id = Column(Integer, ForeignKey("shots.id")) 15 | directory_id = Column(Integer, ForeignKey("directories.id")) 16 | num = Column(Integer) 17 | otio = Column(JSON) 18 | 19 | assignment = relationship("Assignment", uselist=False, back_populates="shot_version") 20 | shot = relationship("Shot", uselist=False, back_populates="versions") 21 | directory = relationship("Directory") 22 | -------------------------------------------------------------------------------- /magla/db/directory.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String 2 | from sqlalchemy.dialects.postgresql import JSON 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class Directory(MaglaORM._Base): 9 | __tablename__ = "directories" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "Directory" 12 | 13 | id = Column(Integer, primary_key=True) 14 | machine_id = Column(Integer, ForeignKey("machines.id")) 15 | user_id = Column(Integer, ForeignKey("users.id")) 16 | label = Column(String) 17 | path = Column(String) 18 | tree = Column(JSON) 19 | bookmarks = Column(JSON) 20 | 21 | machine = relationship("Machine", uselist=False, back_populates="directories") 22 | user = relationship("User", uselist=False, back_populates="directories") 23 | -------------------------------------------------------------------------------- /magla/db/episode.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String 2 | from sqlalchemy.dialects.postgresql import JSON 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class Episode(MaglaORM._Base): 9 | __tablename__ = "episodes" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "Episode" 12 | 13 | id = Column(Integer, primary_key=True) 14 | project_id = Column(Integer, ForeignKey("projects.id")) 15 | directory_id = Column(Integer, ForeignKey("directories.id")) 16 | name = Column(String) 17 | otio = Column(JSON) 18 | 19 | project = relationship("Project", uselist=False, back_populates="episodes") 20 | sequences = relationship("Sequence", back_populates="episode") 21 | shots = relationship("Shot", back_populates="episode") 22 | directory = relationship("Directory") 23 | -------------------------------------------------------------------------------- /magla/db/__init__.py: -------------------------------------------------------------------------------- 1 | """Database module containing ORM interface and SQLAlchemy mapped entity class definitions.""" 2 | from .assignment import Assignment 3 | from .context import Context 4 | from .dependency import Dependency 5 | from .directory import Directory 6 | from .episode import Episode 7 | from .facility import Facility 8 | from .file_type import FileType 9 | from .machine import Machine 10 | from .orm import MaglaORM as ORM 11 | from .orm import database_exists, create_database, drop_database 12 | from .project import Project 13 | from .settings_2d import Settings2D 14 | from .sequence import Sequence 15 | from .shot import Shot 16 | from .shot_version import ShotVersion 17 | from .timeline import Timeline 18 | from .tool import Tool 19 | from .tool_config import ToolConfig 20 | from .tool_version import ToolVersion 21 | from .tool_version_installation import ToolVersionInstallation 22 | from .user import User 23 | -------------------------------------------------------------------------------- /magla/core/errors.py: -------------------------------------------------------------------------------- 1 | """Root exception definitions for `magla` as well as associated logging logic.""" 2 | import logging 3 | 4 | 5 | class MaglaError(Exception): 6 | """Base Exception class. 7 | :attr msg: message str given from raiser. 8 | :type msg: str 9 | """ 10 | 11 | def __init__(self, message, *args, **kwargs): 12 | """Initialize with message str. 13 | :entity_test_fixture msg: message describing exception from raiser. 14 | """ 15 | super(MaglaError, self).__init__(*args, **kwargs) 16 | self.message = message 17 | logging.error(self.__str__()) 18 | 19 | def __str__(self): 20 | return "<{error}: {msg}>".format(error=self.__class__.__name__, msg=self.message) 21 | 22 | 23 | class ConfigPathError(MaglaError): 24 | """Unable to read from the given filepath.""" 25 | 26 | 27 | class ConfigReadError(MaglaError): 28 | """Unable to parse contents of config to dict.""" 29 | -------------------------------------------------------------------------------- /magla/db/tool_version.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String 2 | from sqlalchemy.orm import relationship 3 | 4 | from ..db.orm import MaglaORM 5 | 6 | 7 | class ToolVersion(MaglaORM._Base): 8 | __tablename__ = "tool_versions" 9 | __table_args__ = {'extend_existing': True} 10 | __entity_name__ = "ToolVersion" 11 | 12 | id = Column(Integer, primary_key=True) 13 | string = Column(String) 14 | tool_id = Column(Integer, ForeignKey("tools.id")) 15 | file_types_id = Column(Integer, ForeignKey("file_types.id")) 16 | file_extension = Column(String) 17 | 18 | installations = relationship("ToolVersionInstallation", back_populates="tool_version") 19 | tool = relationship("Tool", uselist=False, back_populates="versions") 20 | tool_config = relationship("ToolConfig", uselist=False, back_populates="tool_version") 21 | file_types = relationship("FileType", back_populates="tool_versions") 22 | -------------------------------------------------------------------------------- /magla/db/sequence.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String 2 | from sqlalchemy.dialects.postgresql import JSON 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class Sequence(MaglaORM._Base): 9 | __tablename__ = "sequences" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "Sequence" 12 | 13 | id = Column(Integer, primary_key=True) 14 | project_id = Column(Integer, ForeignKey("projects.id")) 15 | episode_id = Column(Integer, ForeignKey("episodes.id")) 16 | directory_id = Column(Integer, ForeignKey("directories.id")) 17 | name = Column(String) 18 | otio = Column(JSON) 19 | 20 | project = relationship("Project", uselist=False, back_populates="sequences") 21 | episode = relationship("Episode", uselist=False, back_populates="sequences") 22 | shots = relationship("Shot", back_populates="sequence") 23 | directory = relationship("Directory") 24 | -------------------------------------------------------------------------------- /tests/phase_2/test_root.py: -------------------------------------------------------------------------------- 1 | from attr import validate 2 | from magla.utils import otio_to_dict 3 | import pytest 4 | 5 | from magla.core.root import MaglaRoot 6 | from magla.test import MaglaEntityTestFixture 7 | 8 | class TestRoot(MaglaEntityTestFixture): 9 | 10 | @pytest.fixture(scope="class") 11 | def dummy_root(self, entity_test_fixture): 12 | yield MaglaRoot() 13 | 14 | def test_can_retrieve_orm(self, dummy_root): 15 | assert dummy_root.orm 16 | 17 | def test_can_retrieve_all(self, dummy_root): 18 | all_magla_objects = dummy_root.all() 19 | for magla_object_list in all_magla_objects: 20 | for magla_object in magla_object_list: 21 | obj_dict = magla_object.dict(otio_as_dict=True) 22 | seed_data_dict = self.get_seed_data(magla_object.__schema__.__entity_name__, magla_object_list.index(magla_object)) 23 | if obj_dict != seed_data_dict: 24 | obj_dict 25 | assert obj_dict == seed_data_dict -------------------------------------------------------------------------------- /magla/db/tool_config.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer 2 | from sqlalchemy.dialects.postgresql import JSON 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class ToolConfig(MaglaORM._Base): 9 | """A single configuration for a tool, usually specific to the Project.""" 10 | __tablename__ = "tool_configs" 11 | __table_args__ = {'extend_existing': True} 12 | __entity_name__ = "ToolConfig" 13 | 14 | id = Column(Integer, primary_key=True) 15 | project_id = Column(Integer, ForeignKey("projects.id")) 16 | tool_version_id = Column(Integer, ForeignKey("tool_versions.id")) 17 | directory_id = Column(Integer, ForeignKey("directories.id")) 18 | env = Column(JSON) 19 | copy_dict = Column(JSON) 20 | 21 | project = relationship("Project", uselist=False, back_populates="tool_configs") 22 | tool_version = relationship("ToolVersion", uselist=False, back_populates="tool_config") 23 | directory = relationship("Directory", uselist=False) 24 | -------------------------------------------------------------------------------- /magla/db/project.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String 2 | from sqlalchemy.dialects.postgresql import JSON 3 | from sqlalchemy.orm import relationship 4 | 5 | from ..db.orm import MaglaORM 6 | 7 | 8 | class Project(MaglaORM._Base): 9 | __tablename__ = "projects" 10 | __table_args__ = {'extend_existing': True} 11 | __entity_name__ = "Project" 12 | 13 | id = Column(Integer, primary_key=True) 14 | directory_id = Column(Integer, ForeignKey("directories.id")) 15 | timeline_id = Column(Integer, ForeignKey("timelines.id")) 16 | name = Column(String) 17 | settings = Column(JSON) 18 | 19 | timeline = relationship("Timeline") 20 | settings_2d = relationship("Settings2D", uselist=False, back_populates="project") 21 | episodes = relationship("Episode", back_populates="project") 22 | sequences = relationship("Sequence", back_populates="project") 23 | shots = relationship("Shot", back_populates="project") 24 | tool_configs = relationship("ToolConfig", back_populates="project") 25 | directory = relationship("Directory") 26 | -------------------------------------------------------------------------------- /magla/db/shot.py: -------------------------------------------------------------------------------- 1 | from magla.db import episode, sequence 2 | from sqlalchemy import Column, ForeignKey, Integer, String 3 | from sqlalchemy.dialects.postgresql import JSON 4 | from sqlalchemy.orm import relationship 5 | 6 | from ..db.orm import MaglaORM 7 | 8 | 9 | class Shot(MaglaORM._Base): 10 | __tablename__ = "shots" 11 | __table_args__ = {'extend_existing': True} 12 | __entity_name__ = "Shot" 13 | 14 | id = Column(Integer, primary_key=True) 15 | project_id = Column(Integer, ForeignKey("projects.id")) 16 | directory_id = Column(Integer, ForeignKey("directories.id")) 17 | episode_id = Column(Integer, ForeignKey("episodes.id")) 18 | sequence_id = Column(Integer, ForeignKey("sequences.id")) 19 | name = Column(String) 20 | otio = Column(JSON) 21 | track_index = Column(Integer) 22 | start_frame_in_parent = Column(Integer) 23 | 24 | project = relationship("Project", uselist=False, back_populates="shots") 25 | episode = relationship("Episode", uselist=False, back_populates="shots") 26 | sequence = relationship("Sequence", uselist=False, back_populates="shots") 27 | versions = relationship("ShotVersion", back_populates="shot") 28 | directory = relationship("Directory") 29 | -------------------------------------------------------------------------------- /magla/core/__init__.py: -------------------------------------------------------------------------------- 1 | """Core module for `magla`""" 2 | from .assignment import MaglaAssignment as Assignment 3 | from .config import MaglaConfig as Config 4 | from .context import MaglaContext as Context 5 | from .data import MaglaData as Data 6 | from .dependency import MaglaDependency as Dependency 7 | from .directory import MaglaDirectory as Directory 8 | from .entity import MaglaEntity as Entity 9 | from .episode import MaglaEpisode as Episode 10 | from .facility import MaglaFacility as Facility 11 | from .file_type import MaglaFileType as FileType 12 | from .machine import MaglaMachine as Machine 13 | from .project import MaglaProject as Project 14 | from .root import MaglaRoot as Root 15 | from .settings_2d import MaglaSettings2D as Settings2D 16 | from .sequence import MaglaSequence as Sequence 17 | from .shot import MaglaShot as Shot 18 | from .shot_version import MaglaShotVersion as ShotVersion 19 | from .timeline import MaglaTimeline as Timeline 20 | from .tool import MaglaTool as Tool 21 | from .tool_config import MaglaToolConfig as ToolConfig 22 | from .tool_version import MaglaToolVersion as ToolVersion 23 | from .tool_version_installation import MaglaToolVersionInstallation as ToolVersionInstallation 24 | from .user import MaglaUser as User 25 | 26 | from ..utils import get_machine_uuid, write_machine_uuid 27 | 28 | if not get_machine_uuid(): 29 | write_machine_uuid() 30 | -------------------------------------------------------------------------------- /magla/core/episode.py: -------------------------------------------------------------------------------- 1 | import opentimelineio as otio 2 | 3 | from ..db.episode import Episode 4 | from .entity import MaglaEntity 5 | from .errors import MaglaError 6 | 7 | 8 | class MaglaEpisodeError(MaglaError): 9 | """An error accured preventing MaglaEpisode to continue.""" 10 | 11 | 12 | class MaglaEpisode(MaglaEntity): 13 | """Provide an interface for shot properties and assignment.""" 14 | __schema__ = Episode 15 | 16 | def __init__(self, data=None, **kwargs): 17 | """Initialize with given data. 18 | 19 | Parameters 20 | ---------- 21 | data : dict 22 | Data to query for matching backend record 23 | """ 24 | super(MaglaEpisode, self).__init__(data or dict(kwargs)) 25 | 26 | @property 27 | def id(self): 28 | """Retrieve id from data. 29 | 30 | Returns 31 | ------- 32 | int 33 | Postgres column id 34 | """ 35 | return self.data.id 36 | 37 | @property 38 | def name(self): 39 | """Retrieve name from data. 40 | 41 | Returns 42 | ------- 43 | str 44 | Name of the shot 45 | """ 46 | return self.data.name 47 | 48 | @property 49 | def otio(self): 50 | """Retrieve otio from data. 51 | 52 | Returns 53 | ------- 54 | opentimelineio.schema.Clip 55 | The `Clip` object for this shot 56 | """ 57 | return self.data.otio -------------------------------------------------------------------------------- /magla/core/sequence.py: -------------------------------------------------------------------------------- 1 | import opentimelineio as otio 2 | 3 | from ..db.sequence import Sequence 4 | from .entity import MaglaEntity 5 | from .errors import MaglaError 6 | 7 | 8 | class MaglaSequenceError(MaglaError): 9 | """An error accured preventing MaglaSequence to continue.""" 10 | 11 | 12 | class MaglaSequence(MaglaEntity): 13 | """Provide an interface for shot properties and assignment.""" 14 | __schema__ = Sequence 15 | 16 | def __init__(self, data=None, **kwargs): 17 | """Initialize with given data. 18 | 19 | Parameters 20 | ---------- 21 | data : dict 22 | Data to query for matching backend record 23 | """ 24 | super(MaglaSequence, self).__init__(data or dict(kwargs)) 25 | 26 | @property 27 | def id(self): 28 | """Retrieve id from data. 29 | 30 | Returns 31 | ------- 32 | int 33 | Postgres column id 34 | """ 35 | return self.data.id 36 | 37 | @property 38 | def name(self): 39 | """Retrieve name from data. 40 | 41 | Returns 42 | ------- 43 | str 44 | Name of the shot 45 | """ 46 | return self.data.name 47 | 48 | @property 49 | def otio(self): 50 | """Retrieve otio from data. 51 | 52 | Returns 53 | ------- 54 | opentimelineio.schema.Clip 55 | The `Clip` object for this shot 56 | """ 57 | return self.data.otio -------------------------------------------------------------------------------- /magla/__init__.py: -------------------------------------------------------------------------------- 1 | """MagLa API for Content Creators. 2 | 3 | Magla is an effort to bring the magic of large-scale professional visual effects pipelines to 4 | small-scale studios and freelancers - for free. Magla features a backend designed to re-enforce the 5 | contextual relationships between things in a visual effects pipeline - a philosophy which is at the 6 | core of Magla's design. The idea is that with any given MaglaEntity one can traverse through all 7 | the related entities as they exist in the DB. This is achieved with a Postgres + SQLAlchemy 8 | combination allowing for an excellent object-oriented interface with powerful SQL queries and 9 | relationships behind it. 10 | """ 11 | from .core import (Assignment, Config, Context, Data, Dependency, Directory, 12 | Entity, Facility, FileType, Machine, Project, Root, 13 | Settings2D, Shot, ShotVersion, Timeline, Tool, ToolConfig, 14 | ToolVersion, ToolVersionInstallation, User) 15 | 16 | from .test import MaglaTestFixture, MaglaEntityTestFixture 17 | 18 | # register all sub-entities here 19 | Entity.__types__ = { 20 | "Assignment": Assignment, 21 | "Directory": Directory, 22 | "Facility": Facility, 23 | "Machine": Machine, 24 | "Project": Project, 25 | "Context": Context, 26 | "Settings2D": Settings2D, 27 | "Shot": Shot, 28 | "ShotVersion": ShotVersion, 29 | "Timeline": Timeline, 30 | "Tool": Tool, 31 | "ToolConfig": ToolConfig, 32 | "ToolVersion": ToolVersion, 33 | "ToolVersionInstallation": ToolVersionInstallation, 34 | "User": User 35 | } 36 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md", "r") as fh: 4 | long_description = fh.read() 5 | 6 | setuptools.setup( 7 | name="magla-jacobmartinez3d", 8 | version="0.0.181", 9 | author="Jacob Martinez", 10 | author_email="info@magnetic-lab.com", 11 | description="A free data pipeline for animation and visual-effects freelancers and studios, with an emphasis on dynamically generated `opentimelineio` timelines.", 12 | license="GNU General Public License v3 or later (GPLv3+)", 13 | platform="OS Independent", 14 | py_modules=["magla"], 15 | install_requires=[ 16 | "appdirs", 17 | "cmake", 18 | "opentimelineio>=0.13", 19 | "PyYAML", 20 | "psycopg2", 21 | "pyaaf2", 22 | "sqlalchemy", 23 | "sqlalchemy-utils==0.36.6; python_version < '3.0'", 24 | "sqlalchemy-utils>=0.36.6; python_version >= '3.0'" 25 | ], 26 | extras_require={ 27 | "dev": [ 28 | "flake8", 29 | "pytest", 30 | "pytest-cov", 31 | ] 32 | }, 33 | long_description=long_description, 34 | long_description_content_type="text/markdown", 35 | url="https://github.com/magnetic-lab/magla", 36 | packages=setuptools.find_packages(), 37 | classifiers=[ 38 | "Development Status :: 2 - Pre-Alpha", 39 | "Programming Language :: Python :: 2.7", 40 | "Programming Language :: Python :: 3.6", 41 | "Programming Language :: Python :: 3.7", 42 | "Programming Language :: Python :: 3.8", 43 | "Programming Language :: Python :: 3.9", 44 | "Operating System :: OS Independent", 45 | "Framework :: SQLAlchemy", 46 | "Topic :: Multimedia :: Video" 47 | ], 48 | python_requires='>=2.7, <=3.9', 49 | keywords="vfx post-production animation editing pipeline opentimelineio sql" 50 | ) 51 | -------------------------------------------------------------------------------- /tests/phase_1/test_tool_version_installation.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.tool_version_installation`""" 2 | import pytest 3 | from magla.core.tool_version_installation import MaglaToolVersionInstallation 4 | from magla.test import MaglaEntityTestFixture 5 | 6 | 7 | class TestToolVersionInstallation(MaglaEntityTestFixture): 8 | 9 | _repr_string = "" 10 | 11 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("ToolVersionInstallation")) 12 | def seed_tool_version_installation(self, request, entity_test_fixture): 13 | data, expected_result = request.param 14 | yield MaglaToolVersionInstallation(data) 15 | 16 | def test_can_retrieve_directory(self, seed_tool_version_installation): 17 | backend_data = seed_tool_version_installation.directory.data.dict() 18 | seed_data = self.get_seed_data("Directory", seed_tool_version_installation.directory.id-1) 19 | assert backend_data == seed_data 20 | 21 | def test_can_retrieve_tool_version(self, seed_tool_version_installation): 22 | backend_data = seed_tool_version_installation.tool_version.data.dict() 23 | seed_data = self.get_seed_data("ToolVersion", seed_tool_version_installation.tool_version.id-1) 24 | assert backend_data == seed_data 25 | 26 | def test_can_retieve_tool(self, seed_tool_version_installation): 27 | backend_data = seed_tool_version_installation.tool.data.dict() 28 | seed_data = self.get_seed_data("Tool", seed_tool_version_installation.tool.id-1) 29 | assert backend_data == seed_data 30 | 31 | def test_object_string_repr(self, seed_tool_version_installation): 32 | assert str(seed_tool_version_installation) == self._repr_string.format( 33 | this=seed_tool_version_installation 34 | ) 35 | -------------------------------------------------------------------------------- /tests/phase_1/test_facility.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.facility`""" 2 | import string 3 | 4 | import pytest 5 | from magla.core.facility import MaglaFacility 6 | from magla.test import MaglaEntityTestFixture 7 | from magla.utils import random_string 8 | 9 | 10 | class TestFacility(MaglaEntityTestFixture): 11 | 12 | _repr_string = "" 13 | 14 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("Facility")) 15 | def seed_facility(self, request, entity_test_fixture): 16 | data, expected_result = request.param 17 | yield MaglaFacility(data) 18 | 19 | def test_can_update_name(self, seed_facility): 20 | random_name = random_string(string.ascii_letters, 10) 21 | seed_facility.data.name = random_name 22 | seed_facility.data.push() 23 | name = MaglaFacility(id=seed_facility.id).name 24 | self.reset(seed_facility) 25 | assert name == random_name 26 | 27 | def test_can_update_settings(self, seed_facility): 28 | reset_data = seed_facility.dict() 29 | dummy_settings = { 30 | "setting1": "value1", 31 | "setting2": 2, 32 | "setting3": { 33 | "sub_setting1": "sub_value1", 34 | "sub_setting2": 2 35 | } 36 | } 37 | seed_facility.data.settings = dummy_settings 38 | seed_facility.data.push() 39 | settings = MaglaFacility(id=seed_facility.id).settings 40 | self.reset(seed_facility) 41 | assert settings == dummy_settings 42 | 43 | def test_can_retrieve_machines(self, seed_facility): 44 | backend_data = seed_facility.machines[0].dict() 45 | seed_data = self.get_seed_data("Machine", seed_facility.machines[0].id-1) 46 | assert len(seed_facility.machines) == 1 and backend_data == seed_data 47 | 48 | def test_object_string_repr(self, seed_facility): 49 | assert str(seed_facility) == self._repr_string.format( 50 | this=seed_facility 51 | ) 52 | -------------------------------------------------------------------------------- /tests/phase_1/test_assignment.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.assignment`""" 2 | import random 3 | import string 4 | 5 | import pytest 6 | from magla.core.assignment import MaglaAssignment 7 | from magla.test import MaglaEntityTestFixture 8 | from magla.utils import random_string 9 | 10 | 11 | class TestAssignment(MaglaEntityTestFixture): 12 | 13 | _repr_string = ", user=>" 14 | 15 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("Assignment")) 16 | def seed_assignment(self, request, entity_test_fixture): 17 | data, expected_result = request.param 18 | yield MaglaAssignment(data) 19 | 20 | def test_can_retrieve_shot_version(self, seed_assignment): 21 | backend_data = seed_assignment.shot_version.dict() 22 | seed_data = self.get_seed_data( 23 | "ShotVersion", seed_assignment.shot_version.id-1) 24 | assert backend_data == seed_data 25 | 26 | def test_can_retrieve_user(self, seed_assignment): 27 | backend_data = seed_assignment.user.dict() 28 | seed_data = self.get_seed_data("User", seed_assignment.user.id-1) 29 | assert backend_data == seed_data 30 | 31 | def test_can_retrieve_shot(self, seed_assignment): 32 | backend_data = seed_assignment.shot.dict() 33 | from_seed_data = self.get_seed_data("Shot", seed_assignment.shot.id-1) 34 | assert backend_data == from_seed_data 35 | 36 | def test_can_retrieve_project(self, seed_assignment): 37 | backend_data = seed_assignment.project.dict() 38 | from_seed_data = self.get_seed_data( 39 | "Project", seed_assignment.project.id-1) 40 | assert backend_data == from_seed_data 41 | 42 | def test_object_string_repr(self, seed_assignment): 43 | assert str(seed_assignment) == self._repr_string.format( 44 | this=seed_assignment 45 | ) 46 | -------------------------------------------------------------------------------- /magla/core/facility.py: -------------------------------------------------------------------------------- 1 | from ..db import Facility 2 | from .entity import MaglaEntity 3 | from .errors import MaglaError 4 | 5 | 6 | class MaglaFacilityError(MaglaError): 7 | """An error accured preventing MaglaFacility to continue.""" 8 | 9 | 10 | class MaglaFacility(MaglaEntity): 11 | """Provide an interface for Facility-level administrative tasks.""" 12 | __schema__ = Facility 13 | 14 | def __init__(self, data=None, **kwargs): 15 | """Initialize with given data. 16 | 17 | Parameters 18 | ---------- 19 | data : dict 20 | Data to query for matching backend record 21 | """ 22 | if isinstance(data, str): 23 | data = {"name": data} 24 | super(MaglaFacility, self).__init__(data or dict(kwargs)) 25 | 26 | @property 27 | def id(self): 28 | """Retrieve id from data. 29 | 30 | Returns 31 | ------- 32 | int 33 | Postgres column id 34 | """ 35 | return self.data.id 36 | 37 | @property 38 | def name(self): 39 | """Retrieve name from data. 40 | 41 | Returns 42 | ------- 43 | str 44 | Name of the facility 45 | """ 46 | return self.data.name 47 | 48 | @property 49 | def settings(self): 50 | """Retrieve settings from data. 51 | 52 | Returns 53 | ------- 54 | dict 55 | Settings for the facility 56 | """ 57 | return self.data.settings 58 | 59 | # SQAlchemy relationship back-references 60 | @property 61 | def machines(self): 62 | """Shortcut method to retrieve related `MaglaMachine` back-reference list. 63 | 64 | Returns 65 | ------- 66 | list of magla.core.machine.MaglaMachine 67 | The `MaglaMachine` records for this facility 68 | """ 69 | r = self.data.record.machines 70 | if r == None: 71 | raise MaglaFacilityError( 72 | "No 'machines' record found for {}!".format(self)) 73 | return [self.from_record(a) for a in r] 74 | -------------------------------------------------------------------------------- /tests/phase_1/test_tool.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.seed_tool`""" 2 | import string 3 | 4 | import pytest 5 | from magla.core.tool import MaglaTool 6 | from magla.test import MaglaEntityTestFixture 7 | from magla.utils import random_string 8 | 9 | 10 | class TestTool(MaglaEntityTestFixture): 11 | 12 | _repr_string = "" 13 | 14 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("Tool")) 15 | def seed_tool(self, request, entity_test_fixture): 16 | data, expected_result = request.param 17 | yield MaglaTool(data) 18 | 19 | def test_can_instantiate_with_string_arg(self, seed_tool): 20 | seed_data = self.get_seed_data("Tool", seed_tool.id-1) 21 | tool = MaglaTool(seed_data["name"]) 22 | assert tool.dict() == seed_data 23 | 24 | def test_can_update_name(self, seed_tool): 25 | random_tool_name = random_string(string.ascii_letters, 6) 26 | seed_tool.data.name = random_tool_name 27 | seed_tool.data.push() 28 | tool_name = MaglaTool(id=seed_tool.id).name 29 | self.reset(seed_tool) 30 | assert tool_name == random_tool_name 31 | 32 | def test_can_update_description(self, seed_tool): 33 | random_tool_description = random_string(string.ascii_letters, 300) 34 | seed_tool.data.description = random_tool_description 35 | seed_tool.data.push() 36 | tool_description = MaglaTool(id=seed_tool.id).description 37 | self.reset(seed_tool) 38 | assert tool_description == random_tool_description 39 | 40 | def test_can_retrieve_versions(self, seed_tool): 41 | assert seed_tool.versions 42 | 43 | def test_can_retrieve_latest(self, seed_tool): 44 | assert seed_tool.latest.id == 1 45 | 46 | def test_can_retrieve_default_version(self, seed_tool): 47 | assert seed_tool.default_version.id == 1 48 | 49 | def test_can_pre_startup(self, seed_tool): 50 | assert seed_tool.pre_startup() 51 | 52 | def test_can_post_startup(self, seed_tool): 53 | assert seed_tool.post_startup() 54 | 55 | def test_object_string_repr(self, seed_tool): 56 | assert str(seed_tool) == self._repr_string.format( 57 | this=seed_tool 58 | ) -------------------------------------------------------------------------------- /tests/phase_1/test_timeline.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.seed_timeline`""" 2 | import string 3 | 4 | import pytest 5 | from magla.core.timeline import MaglaTimeline 6 | from magla.core.shot import MaglaShot 7 | from magla.test import MaglaEntityTestFixture 8 | from magla.utils import random_string 9 | 10 | 11 | class TestTimeline(MaglaEntityTestFixture): 12 | 13 | _repr_string = "" 14 | 15 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("Timeline")) 16 | def seed_timeline(self, request, entity_test_fixture): 17 | data, expected_result = request.param 18 | yield MaglaTimeline(data) 19 | 20 | def test_can_update_label(self, seed_timeline): 21 | random_label = random_string(string.ascii_letters, 10) 22 | seed_timeline.data.label = random_label 23 | seed_timeline.data.push() 24 | label = MaglaTimeline(id=seed_timeline.id).label 25 | self.reset(seed_timeline) 26 | assert label == random_label 27 | 28 | def test_can_update_otio(self, seed_timeline): 29 | random_name = random_string(string.ascii_letters, 10) 30 | seed_timeline.data.otio.name = random_name 31 | seed_timeline.data.push() 32 | otio_ = MaglaTimeline(id=seed_timeline.id).otio 33 | self.reset(seed_timeline) 34 | assert otio_.name == random_name 35 | 36 | def test_can_update_user(self, seed_timeline): 37 | new_user_id = 2 38 | seed_timeline.data.user_id = new_user_id 39 | seed_timeline.data.push() 40 | timeline_user_id = MaglaTimeline(id=seed_timeline.id).user.id 41 | self.reset(seed_timeline) 42 | assert timeline_user_id == new_user_id 43 | 44 | def test_object_string_repr(self, seed_timeline): 45 | assert str(seed_timeline) == self._repr_string.format( 46 | this=seed_timeline 47 | ) 48 | 49 | def test_can_insert_shot(self, seed_timeline): 50 | track_0_len = 0 51 | if len(seed_timeline.otio.tracks) > 0: 52 | track_0_len = len(seed_timeline.otio.tracks[0]) 53 | seed_timeline.insert_shot(MaglaShot(id=1)) 54 | assert len(seed_timeline.otio.tracks[0]) == track_0_len + 1 55 | # insert again to test default behavior with no clip index 56 | # seed_timeline.insert_shot(MaglaShot(id=1)) 57 | # assert len(seed_timeline.otio.tracks[0]) == track_0_len + 2 58 | -------------------------------------------------------------------------------- /test_project.json: -------------------------------------------------------------------------------- 1 | { 2 | "OTIO_SCHEMA": "Timeline.1", 3 | "metadata": {}, 4 | "name": "test_project", 5 | "global_start_time": null, 6 | "tracks": { 7 | "OTIO_SCHEMA": "Stack.1", 8 | "metadata": {}, 9 | "name": "tracks", 10 | "source_range": null, 11 | "effects": [], 12 | "markers": [], 13 | "children": [ 14 | { 15 | "OTIO_SCHEMA": "Track.1", 16 | "metadata": {}, 17 | "name": "background", 18 | "source_range": null, 19 | "effects": [], 20 | "markers": [], 21 | "children": [ 22 | { 23 | "OTIO_SCHEMA": "Clip.1", 24 | "metadata": {}, 25 | "name": "test_shot", 26 | "source_range": null, 27 | "effects": [], 28 | "markers": [], 29 | "media_reference": { 30 | "OTIO_SCHEMA": "ImageSequenceReference.1", 31 | "metadata": {}, 32 | "name": "", 33 | "available_range": { 34 | "OTIO_SCHEMA": "TimeRange.1", 35 | "duration": { 36 | "OTIO_SCHEMA": "RationalTime.1", 37 | "rate": 30.0, 38 | "value": 1.0 39 | }, 40 | "start_time": { 41 | "OTIO_SCHEMA": "RationalTime.1", 42 | "rate": 30.0, 43 | "value": 1.0 44 | } 45 | }, 46 | "target_url_base": "", 47 | "name_prefix": "test_project_test_shot_v001.", 48 | "name_suffix": ".png", 49 | "start_frame": 1, 50 | "frame_step": 1, 51 | "rate": 30.0, 52 | "frame_zero_padding": 4, 53 | "missing_frame_policy": "error" 54 | } 55 | } 56 | ], 57 | "kind": "Video" 58 | } 59 | ] 60 | } 61 | } -------------------------------------------------------------------------------- /tests/test_project.otio: -------------------------------------------------------------------------------- 1 | { 2 | "OTIO_SCHEMA": "Timeline.1", 3 | "metadata": {}, 4 | "name": "test_project", 5 | "global_start_time": null, 6 | "tracks": { 7 | "OTIO_SCHEMA": "Stack.1", 8 | "metadata": {}, 9 | "name": "tracks", 10 | "source_range": null, 11 | "effects": [], 12 | "markers": [], 13 | "children": [ 14 | { 15 | "OTIO_SCHEMA": "Track.1", 16 | "metadata": {}, 17 | "name": "background", 18 | "source_range": null, 19 | "effects": [], 20 | "markers": [], 21 | "children": [ 22 | { 23 | "OTIO_SCHEMA": "Clip.1", 24 | "metadata": {}, 25 | "name": "test_shot", 26 | "source_range": null, 27 | "effects": [], 28 | "markers": [], 29 | "media_reference": { 30 | "OTIO_SCHEMA": "ImageSequenceReference.1", 31 | "metadata": {}, 32 | "name": "", 33 | "available_range": { 34 | "OTIO_SCHEMA": "TimeRange.1", 35 | "duration": { 36 | "OTIO_SCHEMA": "RationalTime.1", 37 | "rate": 30.0, 38 | "value": 1.0 39 | }, 40 | "start_time": { 41 | "OTIO_SCHEMA": "RationalTime.1", 42 | "rate": 30.0, 43 | "value": 1.0 44 | } 45 | }, 46 | "target_url_base": "", 47 | "name_prefix": "test_project_test_shot_v001.", 48 | "name_suffix": ".png", 49 | "start_frame": 1, 50 | "frame_step": 1, 51 | "rate": 30.0, 52 | "frame_zero_padding": 4, 53 | "missing_frame_policy": "error" 54 | } 55 | } 56 | ], 57 | "kind": "Video" 58 | } 59 | ] 60 | } 61 | } -------------------------------------------------------------------------------- /magla/core/file_type.py: -------------------------------------------------------------------------------- 1 | """FileType's serve as a centralized definition for all file types in your ecosystem.""" 2 | from ..db.file_type import FileType 3 | from .entity import MaglaEntity 4 | from .errors import MaglaError 5 | 6 | 7 | class MaglaFileTypeError(MaglaError): 8 | """An error accured preventing MaglaFileType to continue.""" 9 | 10 | 11 | class MaglaFileType(MaglaEntity): 12 | """Provide an interface to access information about this type of file.""" 13 | __schema__ = FileType 14 | 15 | def __init__(self, data=None, **kwargs): 16 | """Initialize with given data. 17 | 18 | Parameters 19 | ---------- 20 | data : dict 21 | Data to query for matching backend record 22 | """ 23 | if (not data and not kwargs): 24 | data = {"nickname": MaglaFileType.current()} 25 | 26 | super(MaglaFileType, self).__init__(data or dict(kwargs)) 27 | 28 | @property 29 | def id(self): 30 | """Retrieve id from data. 31 | 32 | Returns 33 | ------- 34 | int 35 | Postgres column id 36 | """ 37 | return self.data.id 38 | 39 | @property 40 | def name(self): 41 | """Retrieve name from data. 42 | 43 | Returns 44 | ------- 45 | str 46 | Name of the file type 47 | """ 48 | return self.data.name 49 | 50 | @property 51 | def extensions(self): 52 | """Retrieve extensions from data. 53 | 54 | Returns 55 | ------- 56 | list 57 | A list of extensions related to this file type 58 | """ 59 | return self.data.extensions 60 | 61 | @property 62 | def description(self): 63 | """Retrieve description from data. 64 | 65 | Returns 66 | ------- 67 | str 68 | Description of the file type 69 | """ 70 | return self.data.description 71 | 72 | # SQAlchemy relationship back-references 73 | @property 74 | def tool_versions(self): 75 | """Shortcut method to retrieve related `MaglaToolVersions` back-reference list. 76 | 77 | Returns 78 | ------- 79 | list of magla.core.tool_version.MaglaToolVersions 80 | The `MaglaToolVersion` records which can read this file type 81 | """ 82 | r = self.data.record.tool_versions 83 | if not r: 84 | return None 85 | return MaglaEntity.from_record(r) 86 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | 116 | # Spyder project settings 117 | .spyderproject 118 | .spyproject 119 | 120 | # Rope project settings 121 | .ropeproject 122 | 123 | # mkdocs documentation 124 | /site 125 | 126 | # mypy 127 | .mypy_cache/ 128 | .dmypy.json 129 | dmypy.json 130 | 131 | # Pyre type checker 132 | .pyre/ 133 | 134 | # pytype static type analyzer 135 | .pytype/ 136 | 137 | # Cython debug symbols 138 | cython_debug/ 139 | 140 | # custom 141 | *.pyc 142 | debug.py 143 | .vscode/* 144 | *.orig 145 | magla/db/data/* 146 | -------------------------------------------------------------------------------- /tests/phase_1/test_tool_version.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.seed_tool_version`""" 2 | import string 3 | 4 | import pytest 5 | from magla.core.tool_version import MaglaToolVersion 6 | from magla.core.tool_version_installation import MaglaToolVersionInstallation 7 | from magla.test import MaglaEntityTestFixture 8 | from magla.utils import random_string 9 | 10 | 11 | class TestToolVersion(MaglaEntityTestFixture): 12 | 13 | _repr_string = "" 14 | 15 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("ToolVersion")) 16 | def seed_tool_version(self, request, entity_test_fixture): 17 | data, expected_result = request.param 18 | yield MaglaToolVersion(data) 19 | 20 | def test_can_update_string(self, seed_tool_version): 21 | random_tool_version_string = random_string(string.ascii_letters, 6) 22 | seed_tool_version.data.string = random_tool_version_string 23 | seed_tool_version.data.push() 24 | tool_version_string = MaglaToolVersion(id=seed_tool_version.id).string 25 | self.reset(seed_tool_version) 26 | assert tool_version_string == random_tool_version_string 27 | 28 | def test_can_update_file_extension(self, seed_tool_version): 29 | random_file_extension = random_string(string.ascii_lowercase, 3) 30 | seed_tool_version.data.file_extension = random_file_extension 31 | seed_tool_version.data.push() 32 | file_extension = MaglaToolVersion(id=seed_tool_version.id).file_extension 33 | self.reset(seed_tool_version) 34 | assert file_extension == random_file_extension 35 | 36 | def test_can_retrieve_tool(self, seed_tool_version): 37 | assert seed_tool_version.tool.id == 1 38 | 39 | def test_can_retrieve_tool_config(self, seed_tool_version): 40 | backend_data = seed_tool_version.tool_config.data.dict() 41 | seed_data = self.get_seed_data("ToolConfig", seed_tool_version.tool_config.id-1) 42 | assert backend_data == seed_data 43 | 44 | def test_can_retrieve_installations(self, seed_tool_version): 45 | # TODO: should this test check the directories' data too? 46 | assert seed_tool_version.installations 47 | 48 | def test_can_generate_full_name(self, seed_tool_version): 49 | assert seed_tool_version.full_name == "{}_{}".format( 50 | seed_tool_version.tool.name, seed_tool_version.string) 51 | 52 | def test_object_string_repr(self, seed_tool_version): 53 | assert str(seed_tool_version) == self._repr_string.format( 54 | this=seed_tool_version 55 | ) 56 | 57 | def test_can_retrieve_installation_by_machine(self, seed_tool_version): 58 | installation = seed_tool_version.installation(machine_id=1) 59 | assert isinstance(installation, MaglaToolVersionInstallation) 60 | -------------------------------------------------------------------------------- /magla/core/dependency.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """TODO: Dependencies can be associated to any other entity and give a means of overriding the 3 | environment associated to that entity. 4 | 5 | Although primarily intended to be used for shot and/or version-specific show-settings overrides, 6 | a `MaglaDependency` could be attached to any entity - having the effect of overriding the current 7 | `magla` environment as long as that entity is involved. This of course will require a definition of 8 | hierarchal inheritence between entities that dont have inherent order like show/shot/shot_version. 9 | """ 10 | import sys 11 | 12 | from ..db.dependency import Dependency 13 | from .entity import MaglaEntity 14 | from .errors import MaglaError 15 | 16 | 17 | class MaglaDependencyError(MaglaError): 18 | """An error accured preventing MaglaDependency to continue.""" 19 | 20 | 21 | class MaglaDependency(MaglaEntity): 22 | """Provide an interface for configuring dependency settings.""" 23 | __schema__ = Dependency 24 | 25 | def __init__(self, data=None, **kwargs): 26 | """Initialize with given data. 27 | 28 | Parameters 29 | ---------- 30 | data : dict, optional 31 | Data to query for matching backend record 32 | """ 33 | super(MaglaDependency, self).__init__(data or dict(kwargs)) 34 | 35 | @property 36 | def id(self): 37 | """Retrieve id from data. 38 | 39 | Returns 40 | ------- 41 | int 42 | Postgres column id 43 | """ 44 | return self.data.id 45 | 46 | @property 47 | def entity_type(self): 48 | """Retrieve entity_type from data. 49 | 50 | Returns 51 | ------- 52 | string 53 | Postgres column entity_type 54 | """ 55 | return self.data.entity_type 56 | 57 | # MaglaDependency-specific methods ________________________________________________________________ 58 | @property 59 | def entity(self): 60 | """Instantiate and return the `MaglaEntity` this dependency definition belongs to. 61 | 62 | Returns 63 | ------- 64 | magla.core.entity.MaglaEntity 65 | The sub-class of the `MaglaEntity` this dependency definition belongs to 66 | """ 67 | return MaglaEntity.type(self.entity_type)(id=self.id) 68 | 69 | @property 70 | def python_exe(self): 71 | """Retrieve the path to the python executeable assigned to this dependency definition. 72 | 73 | Returns 74 | ------- 75 | str 76 | The path on the current machine to the python executeable 77 | """ 78 | return self.data.python_exe \ 79 | or self.python_exe \ 80 | or self.shot.python_exe \ 81 | or self.show.python_exe \ 82 | or sys.executable 83 | -------------------------------------------------------------------------------- /magla/core/assignment.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Assignments connect `MaglaUser` to `MaglaShotVersion`. 3 | 4 | In the `magla` ecosystem versions are considered sacred and can never be assigned to multiple 5 | users, overwritten or changed in any substantial way. For this reason, `MaglaAssignment`'s have the 6 | added responsibility of versioning up the assigned shot. 7 | """ 8 | from ..db.assignment import Assignment 9 | from .entity import MaglaEntity 10 | from .errors import MaglaError 11 | 12 | 13 | class MaglaAssignmentError(MaglaError): 14 | """An error accured preventing MaglaAssignment to continue.""" 15 | 16 | 17 | class MaglaAssignment(MaglaEntity): 18 | """Provide an interface for manipulating `assignments` tables.""" 19 | __schema__ = Assignment 20 | 21 | def __init__(self, data, **kwargs): 22 | """Initialize with given data. 23 | 24 | Parameters 25 | ---------- 26 | data : dict 27 | Data to query for matching backend record 28 | """ 29 | super(MaglaAssignment, self).__init__( 30 | data or dict(kwargs)) 31 | 32 | def __repr__(self): 33 | return "".format(this=self) 34 | 35 | def __str__(self): 36 | return self.__repr__() 37 | 38 | @property 39 | def id(self): 40 | """Retrieve id from data. 41 | 42 | Returns 43 | ------- 44 | int 45 | Postgres column id 46 | """ 47 | return self.data.id 48 | 49 | # SQAlchemy relationship back-references 50 | @property 51 | def shot_version(self): 52 | """Retrieve related `MaglaShotVersion` back-reference. 53 | 54 | Returns 55 | ------- 56 | magla.core.shot_version.MaglaShotVersion 57 | The `MaglaShotVersion` that was initially created for this assignment 58 | """ 59 | r = self.data.record.shot_version 60 | return MaglaEntity.from_record(r) 61 | 62 | @property 63 | def user(self): 64 | """Retrieve related `MaglaUser` back-reference. 65 | 66 | Returns 67 | ------- 68 | magla.core.user.MaglaUser 69 | The `MaglaUser` this assignment belongs to 70 | """ 71 | r = self.data.record.user 72 | return MaglaEntity.from_record(r) 73 | 74 | # MaglaAssignment-specific methods 75 | @property 76 | def shot(self): 77 | """Shortcut method to retrieve related `MaglaShot` back-reference. 78 | 79 | Returns 80 | ------- 81 | magla.core.shot.MaglaShot 82 | The related `MaglaShot` 83 | """ 84 | return self.shot_version.shot 85 | 86 | @property 87 | def project(self): 88 | """Shortcut method to retrieve related `MaglaProject` back-reference. 89 | 90 | Returns 91 | ------- 92 | magla.core.project.MaglaProject 93 | The related `MaglaProject` 94 | """ 95 | return self.shot.project 96 | -------------------------------------------------------------------------------- /.github/workflows/magla_ci.yml: -------------------------------------------------------------------------------- 1 | name: magla-CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - development 7 | - features/** 8 | - bugs/** 9 | pull_request: 10 | tags: 11 | - "auto-pull-request" 12 | - "auto-merge" 13 | - "!WIP" 14 | - "!wip" 15 | 16 | jobs: 17 | build_and_test: 18 | runs-on: ${{ matrix.os }} 19 | continue-on-error: true 20 | strategy: 21 | matrix: 22 | os: [ubuntu-latest, macos-latest] 23 | python-version: [3.6] 24 | steps: 25 | - name: Checkout 26 | uses: actions/checkout@v2 27 | - name: Set up Python ${{ matrix.python-version }} 28 | uses: actions/setup-python@v2 29 | with: 30 | python-version: ${{ matrix.python-version }} 31 | - name: Install Dependencies 32 | run: | 33 | python -m pip install --upgrade pip 34 | pip install .[dev] 35 | - name: Coverage and Test Reports 36 | run: | 37 | coverage run --source magla -m pytest -v 38 | coverage report 39 | env: 40 | MAGLA_DB_DATA_DIR: ${{github.workspace}}/magla/db/data 41 | MAGLA_DB_NAME: "magla_sqlite" 42 | MAGLA_TEST_DIR: ${{github.workspace}}/tests 43 | MAGLA_MACHINE_CONFIG_DIR: ${{github.workspace}}/tests/magla_machine 44 | 45 | auto_pr: 46 | needs: build_and_test 47 | runs-on: ubuntu-latest 48 | outputs: 49 | # this output is accessible by other jobs 50 | number: ${{ steps.create_pr.outputs.pull-request-url }} 51 | steps: 52 | - name: Checkout 53 | uses: actions/checkout@v2 54 | with: 55 | ref: ${{ github.ref }} 56 | - name: Reset ${{ github.head_ref }} Branch 57 | run: | 58 | git fetch origin ${{ github.head_ref }}:${{ github.head_ref }} 59 | git reset --hard ${{ github.head_ref }} 60 | - name: Create Auto-PR 61 | id: create_pr 62 | uses: peter-evans/create-pull-request@v3 63 | with: 64 | branch: ${{ github.ref }} 65 | base: development 66 | labels: | 67 | auto-pull-request 68 | auto-merge 69 | title: Automated development update from ${{ github.actor }} 70 | body: "This automated-pull request is part of `magla`'s continuous integration pipeline and aims to keep the development branch up-to-date with the `${{ github.ref }}` branch." 71 | 72 | auto_merge: 73 | runs-on: ubuntu-latest 74 | needs: auto_pr 75 | if: ${{ github.event_name }} == "pull_request" 76 | env: 77 | PR_NUM: ${{ needs.auto_pr.outputs.number }} 78 | steps: 79 | - name: Merge 80 | uses: actions/github-script@v3.0.0 81 | with: 82 | script: | 83 | var pr_url = "${{ env.PR_NUM }}" 84 | var pr_num = pr_url.substr(pr_url.lastIndexOf('/') + 1) 85 | github.pulls.merge({ 86 | owner: context.payload.repository.owner.login, 87 | repo: context.payload.repository.name, 88 | pull_number: pr_num 89 | }) 90 | github-token: ${{ github.token }} 91 | -------------------------------------------------------------------------------- /tests/phase_1/test_settings2d.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.seed_settings_2d`""" 2 | import random 3 | import string 4 | 5 | import pytest 6 | from magla.core.settings_2d import MaglaSettings2D 7 | from magla.test import MaglaEntityTestFixture 8 | from magla.utils import random_string 9 | 10 | 11 | class TestSettings2D(MaglaEntityTestFixture): 12 | 13 | _repr_string = "" 14 | 15 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("Settings2D")) 16 | def seed_settings_2d(self, request, entity_test_fixture): 17 | data, expected_result = request.param 18 | yield MaglaSettings2D(data) 19 | 20 | def test_can_update_label(self, seed_settings_2d): 21 | random_label = random_string(string.ascii_letters, 10) 22 | seed_settings_2d.data.label = random_label 23 | seed_settings_2d.data.push() 24 | label = MaglaSettings2D(id=seed_settings_2d.id).label 25 | self.reset(seed_settings_2d) 26 | assert label == random_label 27 | 28 | def test_can_update_height(self, seed_settings_2d): 29 | random_height = random.randint(1, 4096*4) 30 | seed_settings_2d.data.height = random_height 31 | seed_settings_2d.data.push() 32 | height = MaglaSettings2D(id=seed_settings_2d.id).height 33 | self.reset(seed_settings_2d) 34 | assert height == random_height 35 | 36 | def test_can_update_width(self, seed_settings_2d): 37 | random_width = random.randint(1, 4096*4) 38 | seed_settings_2d.data.width = random_width 39 | seed_settings_2d.data.push() 40 | width = MaglaSettings2D(id=seed_settings_2d.id).width 41 | self.reset(seed_settings_2d) 42 | assert width == random_width 43 | 44 | def test_can_update_rate(self, seed_settings_2d): 45 | random_rate = random.randint(1, 120) 46 | seed_settings_2d.data.rate = random_rate 47 | seed_settings_2d.data.push() 48 | rate = MaglaSettings2D(id=seed_settings_2d.id).rate 49 | self.reset(seed_settings_2d) 50 | assert rate == random_rate 51 | 52 | def test_can_update_color_profile(self, seed_settings_2d): 53 | random_color_profile = random_string(string.ascii_letters, 100) 54 | seed_settings_2d.data.color_profile = random_color_profile 55 | seed_settings_2d.data.push() 56 | color_profile = MaglaSettings2D(id=seed_settings_2d.id).color_profile 57 | self.reset(seed_settings_2d) 58 | assert color_profile == random_color_profile 59 | 60 | def test_can_retrieve_project(self, seed_settings_2d): 61 | backend_data = seed_settings_2d.project.dict() 62 | seed_data = self.get_seed_data("Project", seed_settings_2d.project.id-1) 63 | self.reset(seed_settings_2d) 64 | assert backend_data == seed_data 65 | 66 | def test_object_string_repr(self, seed_settings_2d): 67 | assert str(seed_settings_2d) == self._repr_string.format( 68 | this=seed_settings_2d 69 | ) 70 | -------------------------------------------------------------------------------- /magla/core/settings_2d.py: -------------------------------------------------------------------------------- 1 | """2D settings are output settings that can be recycled and used accross multiple projects. 2 | 3 | It is especially important to encapsulate any settings and properties that are or would be 4 | associated to video codec specifications. Specialized video codecs will sometimes have inherent 5 | requirements 6 | """ 7 | from .entity import MaglaEntity 8 | from .errors import MaglaError 9 | from ..db.settings_2d import Settings2D 10 | 11 | 12 | class MaglaSettings2DError(MaglaError): 13 | """An error accured preventing MaglaSettings2D to continue.""" 14 | 15 | 16 | class MaglaSettings2D(MaglaEntity): 17 | """Provide interface for accessing and editing 2d output settings.""" 18 | __schema__ = Settings2D 19 | 20 | def __init__(self, data=None, **kwargs): 21 | """Initialize with given data. 22 | 23 | Parameters 24 | ---------- 25 | data : dict 26 | Data to query for matching backend record 27 | """ 28 | super(MaglaSettings2D, self).__init__(data or dict(kwargs)) 29 | 30 | @property 31 | def id(self): 32 | """Retrieve id from data. 33 | 34 | Returns 35 | ------- 36 | int 37 | Postgres column id 38 | """ 39 | return self.data.id 40 | 41 | @property 42 | def label(self): 43 | """Retrieve label. 44 | 45 | Returns 46 | ------- 47 | int 48 | Label description of these 2D-settings 49 | """ 50 | return self.data.label 51 | 52 | @property 53 | def height(self): 54 | """Retrieve height from data. 55 | 56 | Returns 57 | ------- 58 | int 59 | height amount 60 | """ 61 | return self.data.height 62 | 63 | @property 64 | def width(self): 65 | """Retrieve height from data. 66 | 67 | Returns 68 | ------- 69 | int 70 | width amount 71 | """ 72 | return self.data.width 73 | 74 | @property 75 | def rate(self): 76 | """Retrieve rate from data. 77 | 78 | Returns 79 | ------- 80 | int 81 | FPS rate 82 | """ 83 | return self.data.rate 84 | 85 | @property 86 | def color_profile(self): 87 | """Retrieve color_profile from data. 88 | 89 | Returns 90 | ------- 91 | int 92 | FPS color_profile 93 | """ 94 | return self.data.color_profile 95 | 96 | # SQAlchemy relationship back-references 97 | @property 98 | def project(self): 99 | """Shortcut method to retrieve related `MaglaProject` back-reference. 100 | 101 | Returns 102 | ------- 103 | magla.core.project.MaglaProject 104 | The `MaglaProject` owner of this timeline if any 105 | """ 106 | r = self.data.record.project 107 | if not r: 108 | return None 109 | return MaglaEntity.from_record(r) 110 | -------------------------------------------------------------------------------- /tests/phase_1/test_shot_version.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.seed_shot_version`""" 2 | import random 3 | import string 4 | 5 | import pytest 6 | from magla.core.shot_version import MaglaShotVersion 7 | from magla.test import MaglaEntityTestFixture 8 | from magla.utils import random_string 9 | 10 | 11 | class TestShotVersion(MaglaEntityTestFixture): 12 | 13 | _repr_string = "" 14 | 15 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("ShotVersion")) 16 | def seed_shot_version(self, request, entity_test_fixture): 17 | data, expected_result = request.param 18 | yield MaglaShotVersion(data) 19 | 20 | def test_can_update_num(self, seed_shot_version): 21 | random_num = random.randint(0, 115) 22 | seed_shot_version.data.num = random_num 23 | seed_shot_version.data.push() 24 | num = MaglaShotVersion(id=seed_shot_version.id).num 25 | self.reset(seed_shot_version) 26 | assert num == random_num 27 | 28 | def test_can_update_otio(self, seed_shot_version): 29 | random_target_url_base = random_string(string.ascii_letters, 10) 30 | seed_shot_version.data.otio.target_url_base = random_target_url_base 31 | seed_shot_version.data.push() 32 | otio_target_url_base = MaglaShotVersion(id=seed_shot_version.id).otio.target_url_base 33 | self.reset(seed_shot_version) 34 | assert otio_target_url_base == random_target_url_base 35 | 36 | def test_can_retieve_directory(self, seed_shot_version): 37 | backend_data = seed_shot_version.directory.dict() 38 | seed_data = self.get_seed_data("Directory", seed_shot_version.directory.id-1) 39 | assert backend_data == seed_data 40 | 41 | def test_can_retieve_assignment(self, seed_shot_version): 42 | backend_data = seed_shot_version.assignment.dict() 43 | seed_data = self.get_seed_data("Assignment", seed_shot_version.assignment.id-1) 44 | assert backend_data == seed_data 45 | 46 | def test_can_retrieve_shot(self, seed_shot_version): 47 | backend_data = seed_shot_version.shot.dict(otio_as_dict=True) 48 | seed_data = self.get_seed_data("Shot", seed_shot_version.shot.id-1) 49 | assert backend_data == seed_data 50 | 51 | def test_can_retrieve_project(self, seed_shot_version): 52 | backend_data = seed_shot_version.project.dict() 53 | seed_data = self.get_seed_data("Project", seed_shot_version.project.id-1) 54 | assert backend_data == seed_data 55 | 56 | def test_can_generate_name(self, seed_shot_version): 57 | assert seed_shot_version.name == "{sv.shot.name}_v{sv.num:03d}".format( 58 | sv=seed_shot_version) 59 | 60 | def test_can_generate_full_name(self, seed_shot_version): 61 | assert seed_shot_version.full_name == "{sv.project.name}_{sv.shot.name}_v{sv.num:03d}".format( 62 | sv=seed_shot_version) 63 | 64 | def test_object_string_repr(self, seed_shot_version): 65 | assert str(seed_shot_version) == self._repr_string.format( 66 | this=seed_shot_version 67 | ) 68 | -------------------------------------------------------------------------------- /magla/core/tool_version_installation.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A class to manage the execution of tools and log output from their processes.""" 3 | from ..db.tool_version_installation import ToolVersionInstallation 4 | from .entity import MaglaEntity 5 | from .errors import MaglaError 6 | 7 | 8 | class MaglaToolVersionInstallationError(MaglaError): 9 | """An error accured preventing MaglaToolVersionInstallation to continue.""" 10 | 11 | 12 | class MaglaToolVersionInstallation(MaglaEntity): 13 | """A class for running tool executeables with contextual modifications. 14 | 15 | This class is responsible for making sure the proper modifcations are made 16 | each time a tool is launched. Modifications include: 17 | - custom PYTHONPATH insertions 18 | - injected environment variables 19 | - tool/show-specific startup scripts(plugins, gizmos, tox's, etc) 20 | """ 21 | __schema__ = ToolVersionInstallation 22 | 23 | def __init__(self, data=None, **kwargs): 24 | """Initialize with a name for the tool 25 | :entity_test_fixture tool_name: name of the tool to initialize 26 | :type tool_name: str 27 | :raise MaglaToolVersionInstallationNameNotFound: No tool name, or nicknames found 28 | """ 29 | super(MaglaToolVersionInstallation, self).__init__(data or dict(kwargs)) 30 | 31 | def __repr__(self): 32 | return "".format(this=self) 33 | 34 | def __str__(self): 35 | return self.__repr__() 36 | 37 | @property 38 | def id(self): 39 | """Retrieve id from data. 40 | 41 | Returns 42 | ------- 43 | int 44 | Postgres column id 45 | """ 46 | return self.data.id 47 | 48 | # SQAlchemy relationship back-references 49 | @property 50 | def directory(self): 51 | """Shortcut method to retrieve related `MaglaDirectory` back-reference. 52 | 53 | Returns 54 | ------- 55 | magla.core.directory.MaglaDirectory 56 | The `MaglaDirectory` for this tool-version-installation 57 | """ 58 | r = self.data.record.directory 59 | if not r: 60 | return None 61 | return MaglaEntity.from_record(r) 62 | 63 | @property 64 | def tool_version(self): 65 | """Shortcut method to retrieve related `MaglaToolVersion` back-reference. 66 | 67 | Returns 68 | ------- 69 | magla.core.tool_version.MaglaToolVersion 70 | The `MaglaToolVersion` for this tool-version-installation 71 | """ 72 | r = self.data.record.tool_version 73 | if not r: 74 | return None 75 | return MaglaEntity.from_record(r) 76 | 77 | # MaglaToolVersionInstallation-specific methods _____________________________________________ 78 | @property 79 | def tool(self): 80 | """Shortcut method to retrieve related `MaglaTool` back-reference. 81 | 82 | Returns 83 | ------- 84 | magla.core.tool.MaglaTool 85 | The `MaglaTool` for this tool-version-installation 86 | """ 87 | return self.tool_version.tool 88 | -------------------------------------------------------------------------------- /tests/phase_1/test_utils.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | import os 4 | import shutil 5 | import tempfile 6 | import uuid 7 | import yaml 8 | 9 | from opentimelineio.schema import Timeline 10 | import pytest 11 | 12 | from magla import MaglaTestFixture, utils 13 | from magla import db 14 | 15 | 16 | class TestUtils: 17 | 18 | def test_get_machine_uuid(self): 19 | assert isinstance(utils.get_machine_uuid(), str) 20 | 21 | def test_can_convert_nested_otio_to_dict(self): 22 | seed_otio_timeline = utils.otio.adapters.read_from_file(os.path.join(os.environ["MAGLA_TEST_DIR"], "test_project.otio")) 23 | test_data_dict = { 24 | "id": 1, 25 | "otio": seed_otio_timeline, 26 | "name": "test_data_object" 27 | } 28 | result = utils.otio_to_dict(test_data_dict) 29 | assert result["otio"] == json.loads(seed_otio_timeline.to_json_string()) 30 | 31 | def test_can_convert_otio_to_dict(self): 32 | seed_otio_timeline = utils.otio.adapters.read_from_file(os.path.join(os.environ["MAGLA_TEST_DIR"], "test_project.otio")) 33 | result = utils.otio_to_dict(seed_otio_timeline) 34 | assert result == json.loads(seed_otio_timeline.to_json_string()) 35 | 36 | def test_can_get_machine_uuid(self): 37 | machine_config = configparser.ConfigParser() 38 | machine_config.read(os.path.join(os.environ["MAGLA_TEST_DIR"], "magla_machine", "machine.ini")) 39 | assert utils.get_machine_uuid() == machine_config["DEFAULT"].get("uuid") 40 | 41 | def test_raise_machine_config_not_found_error(self): 42 | with pytest.raises(utils.MachineConfigNotFoundError) as err: 43 | utils.get_machine_uuid("1234") 44 | 45 | def test_can_generate_machine_uuid(self): 46 | uuid_ = utils.generate_machine_uuid() 47 | assert isinstance(uuid_, uuid.UUID) and len(str(uuid_)) == 36 48 | 49 | def test_can_write_machine_uuid(self): 50 | temp_machine_config_dir = os.path.join(tempfile.gettempdir(), "temp_machine_config") 51 | if os.path.exists(temp_machine_config_dir): 52 | shutil.rmtree(temp_machine_config_dir) 53 | os.environ["MAGLA_MACHINE_CONFIG_DIR"] = temp_machine_config_dir 54 | utils.write_machine_uuid() 55 | assert os.path.isfile(os.path.join(temp_machine_config_dir, "machine.ini")) 56 | 57 | def test_can_convert_dict_to_otio(self): 58 | seed_timeline_data = MaglaTestFixture.get_seed_data("Timeline", 0) 59 | converted = utils.dict_to_otio(seed_timeline_data) 60 | assert isinstance(converted["otio"], Timeline) 61 | converted_again = utils.dict_to_otio(converted) 62 | assert isinstance(converted_again["otio"], Timeline) 63 | 64 | def test_can_apply_dict_to_record(self): 65 | seed_timeline_data = MaglaTestFixture.get_seed_data("Timeline", 0) 66 | record = utils.apply_dict_to_record(db.Timeline(), seed_timeline_data, otio_as_dict=True) 67 | for k, v in seed_timeline_data.items(): 68 | assert getattr(record, k) == v 69 | 70 | record_with_otio = utils.apply_dict_to_record(db.Timeline(), seed_timeline_data, otio_as_dict=False) 71 | assert isinstance(record_with_otio.otio, Timeline) 72 | 73 | def test_can_open_directory_location(self): 74 | proc = utils.open_directory_location(os.environ["MAGLA_MACHINE_CONFIG_DIR"]) 75 | assert proc 76 | proc.kill() 77 | 78 | -------------------------------------------------------------------------------- /tests/phase_1/test_machine.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.machine`""" 2 | import string 3 | import uuid 4 | 5 | import pytest 6 | from magla.core.machine import MaglaMachine 7 | from magla.test import MaglaEntityTestFixture 8 | from magla.utils import random_string, get_machine_uuid 9 | 10 | 11 | class TestMachine(MaglaEntityTestFixture): 12 | 13 | _repr_string = "" 14 | 15 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("Machine")) 16 | def seed_machine(self, request, entity_test_fixture): 17 | data, expected_result = request.param 18 | yield MaglaMachine(data) 19 | 20 | def test_can_instantiate_with_no_args(self, seed_machine): 21 | confirmation = MaglaMachine() 22 | assert confirmation.dict() == seed_machine.dict() 23 | 24 | def test_can_instantiate_from_uuid(self, seed_machine): 25 | confirmation = MaglaMachine(get_machine_uuid()) 26 | assert confirmation.dict() == seed_machine.dict() 27 | 28 | def test_can_update_facility_to_null(self, seed_machine): 29 | seed_machine.data.facility_id = None 30 | seed_machine.data.push() 31 | null_facility = MaglaMachine(id=seed_machine.id).facility 32 | self.reset(seed_machine) 33 | assert null_facility == None 34 | 35 | def test_can_update_name(self, seed_machine): 36 | random_machine_name = random_string(string.ascii_letters, 6) 37 | seed_machine.data.name = random_machine_name 38 | seed_machine.data.push() 39 | machine_name = MaglaMachine(id=seed_machine.id).name 40 | self.reset(seed_machine) 41 | assert machine_name == random_machine_name 42 | 43 | def test_can_update_ip_address(self, seed_machine): 44 | random_ip_address = random_string(string.ascii_letters, 15) 45 | seed_machine.data.ip_address = random_ip_address 46 | seed_machine.data.push() 47 | ip_address = MaglaMachine(id=seed_machine.id).ip_address 48 | self.reset(seed_machine) 49 | assert ip_address == random_ip_address 50 | 51 | def test_can_update_uuid(self, seed_machine): 52 | get_node = uuid.getnode() 53 | random_int14 = int(random_string(get_node, len(str(get_node)))) 54 | random_uuid = str(uuid.UUID(int=random_int14)) 55 | seed_machine.data.uuid = random_uuid 56 | seed_machine.data.push() 57 | machine_uuid = MaglaMachine(id=seed_machine.id).uuid 58 | self.reset(seed_machine) 59 | assert machine_uuid == random_uuid 60 | 61 | def test_can_retieve_facility(self, seed_machine): 62 | backend_data = seed_machine.facility.dict() 63 | seed_data = self.get_seed_data("Facility", seed_machine.facility.id-1) 64 | assert backend_data == seed_data 65 | 66 | def test_can_retrieve_directories(self, seed_machine): 67 | backend_data = seed_machine.directories[0].dict() 68 | from_seed_data = self.get_seed_data("Directory", seed_machine.directories[0].id-1) 69 | assert backend_data == from_seed_data 70 | 71 | def test_can_retrieve_contexts(self, seed_machine): 72 | backend_data = seed_machine.contexts[0].dict() 73 | from_seed_data = self.get_seed_data("Context", seed_machine.contexts[0].id-1) 74 | assert backend_data == from_seed_data 75 | 76 | def test_object_string_repr(self, seed_machine): 77 | assert str(seed_machine) == self._repr_string.format( 78 | this=seed_machine 79 | ) -------------------------------------------------------------------------------- /tests/phase_1/test_tool_config.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.seed_tool_config`""" 2 | from magla.core.tool import MaglaTool 3 | import pytest 4 | from magla.core.tool_config import MaglaToolConfig 5 | from magla.core.user import MaglaUser 6 | from magla.test import MaglaEntityTestFixture 7 | 8 | 9 | class TestToolConfig(MaglaEntityTestFixture): 10 | 11 | _repr_string = "" 12 | 13 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("ToolConfig")) 14 | def seed_tool_config(self, request, entity_test_fixture): 15 | data, expected_result = request.param 16 | yield MaglaToolConfig(data) 17 | 18 | def test_can_update_env(self, seed_tool_config): 19 | random_env = { 20 | "PYTHONPATH": "/pipeline_share/python/" 21 | } 22 | seed_tool_config.data.env = random_env 23 | seed_tool_config.data.push() 24 | env = MaglaToolConfig(id=seed_tool_config.id).env 25 | self.reset(seed_tool_config) 26 | assert env == random_env 27 | 28 | def test_can_update_copy_dict(self, seed_tool_config): 29 | random_copy_dict = { 30 | "/path/to/source": "/path/to/dest" 31 | } 32 | seed_tool_config.data.copy_dict = random_copy_dict 33 | seed_tool_config.data.push() 34 | copy_dict = MaglaToolConfig(id=seed_tool_config.id).copy_dict 35 | self.reset(seed_tool_config) 36 | assert copy_dict == random_copy_dict 37 | 38 | def test_can_retieve_project(self, seed_tool_config): 39 | backend_data = seed_tool_config.project.dict() 40 | seed_data = self.get_seed_data("Project", seed_tool_config.project.id-1) 41 | assert backend_data == seed_data 42 | 43 | def test_can_retieve_tool_version(self, seed_tool_config): 44 | backend_data = seed_tool_config.tool_version.dict() 45 | seed_data = self.get_seed_data("ToolVersion", seed_tool_config.tool_version.id-1) 46 | assert backend_data == seed_data 47 | 48 | def test_can_retieve_directory(self, seed_tool_config): 49 | backend_data = seed_tool_config.directory.dict() 50 | seed_data = self.get_seed_data("Directory", seed_tool_config.directory.id-1) 51 | assert backend_data == seed_data 52 | 53 | def test_can_retieve_tool(self, seed_tool_config): 54 | backend_data = seed_tool_config.dict() 55 | seed_data = self.get_seed_data("ToolConfig", seed_tool_config.tool.id-1) 56 | assert backend_data == seed_data 57 | 58 | def test_can_build_env(self, seed_tool_config): 59 | # TODO: need to test the contents of env 60 | assert seed_tool_config.build_env() 61 | 62 | def test_object_string_repr(self, seed_tool_config): 63 | assert str(seed_tool_config) == self._repr_string.format( 64 | this=seed_tool_config 65 | ) 66 | 67 | def test_can_instantiate_from_user_context(self): 68 | # with assignment context 69 | tool_config = MaglaToolConfig.from_user_context(tool_id=1, context=MaglaUser("foobar").context) 70 | assert isinstance(tool_config, MaglaToolConfig) 71 | # without assignment context 72 | user_context = MaglaUser("foobar").context 73 | user_context.data.assignment_id = None 74 | user_context.data.push() 75 | tool_config = MaglaToolConfig.from_user_context(tool_id=1, context=MaglaUser("foobar").context) 76 | assert isinstance(tool_config, MaglaToolConfig) 77 | self.reset(user_context) -------------------------------------------------------------------------------- /tests/phase_1/test_user.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.seed_user`""" 2 | import string 3 | import getpass 4 | 5 | import pytest 6 | from magla.core.user import MaglaUser 7 | from magla.test import MaglaEntityTestFixture 8 | from magla.utils import random_string 9 | 10 | 11 | class TestUser(MaglaEntityTestFixture): 12 | 13 | _repr_string = "" 14 | 15 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("User")) 16 | def seed_user(self, request, entity_test_fixture): 17 | data, expected_result = request.param 18 | yield MaglaUser(data) 19 | 20 | def test_can_update_nickname(self, seed_user): 21 | random_nickname = random_string(string.ascii_letters, 10) 22 | seed_user.data.nickname = random_nickname 23 | seed_user.data.push() 24 | nickname= MaglaUser(id=seed_user.id).nickname 25 | self.reset(seed_user) 26 | assert nickname== random_nickname 27 | 28 | def test_can_update_first_name(self, seed_user): 29 | random_first_name = random_string(string.ascii_letters, 10) 30 | seed_user.data.first_name = random_first_name 31 | seed_user.data.push() 32 | first_name = MaglaUser(id=seed_user.id).first_name 33 | self.reset(seed_user) 34 | assert first_name == random_first_name 35 | 36 | def test_can_update_last_name(self, seed_user): 37 | random_last_name = random_string(string.ascii_letters, 10) 38 | seed_user.data.last_name = random_last_name 39 | seed_user.data.push() 40 | last_name = MaglaUser(id=seed_user.id).last_name 41 | self.reset(seed_user) 42 | assert last_name == random_last_name 43 | 44 | def test_can_update_email(self, seed_user): 45 | random_email = "{local}@{domain_name}.{domain}".format( 46 | local=random_string(string.ascii_letters, 10), 47 | domain_name=random_string(string.ascii_letters, 10), 48 | domain=random_string(string.ascii_letters, 3) 49 | ) 50 | seed_user.data.email = random_email 51 | seed_user.data.push() 52 | email = MaglaUser(id=seed_user.id).email 53 | self.reset(seed_user) 54 | assert email == random_email 55 | 56 | def test_can_retrieve_null_context(self, seed_user): 57 | backend_data = seed_user.context.dict() 58 | seed_data = self.get_seed_data("Context", seed_user.context.id-1) 59 | assert backend_data == seed_data 60 | 61 | def test_can_retrieve_assignments(self, seed_user): 62 | if seed_user.id == 1: 63 | backend_data = seed_user.assignments[0].dict() 64 | from_seed_data = self.get_seed_data("Assignment", seed_user.assignments[0].id-1) 65 | assert backend_data == from_seed_data 66 | elif seed_user.id == 2: 67 | assert seed_user.assignments == [] 68 | 69 | def test_can_retrieve_directories(self, seed_user): 70 | assert seed_user.directories 71 | 72 | def test_can_retrieve_timelines(self, seed_user): 73 | if seed_user.id == 1: 74 | assert seed_user.timelines == [] 75 | elif seed_user.id == 2: 76 | # better to convert all `otio` objects to dict before comparison 77 | data_from_db = seed_user.timelines[0].dict(otio_as_dict=True) 78 | seed_data = self.get_seed_data("Timeline", seed_user.timelines[0].id-1) 79 | assert data_from_db == seed_data 80 | 81 | def test_can_retrieve_null_directory(self, seed_user): 82 | random_nickname = random_string(string.ascii_letters, 10) 83 | assert seed_user.directory(random_nickname) == None 84 | 85 | def test_object_string_repr(self, seed_user): 86 | assert str(seed_user) == self._repr_string.format( 87 | this=seed_user 88 | ) 89 | 90 | def test_can_retrieve_current_os_user(self): 91 | assert MaglaUser.current() == getpass.getuser() -------------------------------------------------------------------------------- /magla/core/machine.py: -------------------------------------------------------------------------------- 1 | """Machines give access to `MaglaFacility`, `MaglaDirectoriy` and `MaglaTool` related entities. 2 | 3 | Anything in `magla` related to the filesystem at some point must be associated to a machine. Each 4 | machine should be unique to an physical machine which is currently or at one point was used within 5 | your ecosystem. 6 | 7 | The `uuid.getnode` method is used to obtain a unique identifier for the machine which is based off 8 | the current MAC address. Keep in mind this method is not 100% reliable but more than good enough. 9 | """ 10 | import uuid 11 | 12 | from ..db.machine import Machine 13 | from ..utils import get_machine_uuid 14 | from .entity import MaglaEntity 15 | from .errors import MaglaError 16 | 17 | 18 | class MaglaMachineError(MaglaError): 19 | pass 20 | 21 | 22 | class MaglaMachine(MaglaEntity): 23 | """Provide an interface to perform administrative tasks on a machine.""" 24 | __schema__ = Machine 25 | 26 | def __init__(self, data=None, **kwargs): 27 | """Initialize with given data. 28 | 29 | Parameters 30 | ---------- 31 | data : dict 32 | Data to query for matching backend record 33 | """ 34 | if not data and not kwargs: 35 | data = {"uuid": get_machine_uuid()} 36 | elif isinstance(data, uuid.UUID) or isinstance(data, str): 37 | data = {"uuid": str(data)} 38 | super(MaglaMachine, self).__init__(data or dict(kwargs)) 39 | 40 | @property 41 | def id(self): 42 | """Retrieve id from data. 43 | 44 | Returns 45 | ------- 46 | int 47 | Postgres column id 48 | """ 49 | return self.data.id 50 | 51 | @property 52 | def name(self): 53 | """Retrieve name from data. 54 | 55 | Returns 56 | ------- 57 | str 58 | Name of the machine - its hostname 59 | """ 60 | return self.data.name 61 | 62 | @property 63 | def ip_address(self): 64 | """Retrieve ip_address from data. 65 | 66 | Returns 67 | ------- 68 | str 69 | The local facility ip address for this machine 70 | """ 71 | return self.data.ip_address 72 | 73 | @property 74 | def uuid(self): 75 | """Retrieve uuid from data. 76 | 77 | Returns 78 | ------- 79 | uuid.UUID 80 | UUID generated from the machines network MAC address 81 | """ 82 | return self.data.uuid 83 | 84 | # SQAlchemy relationship back-references 85 | @property 86 | def facility(self): 87 | """Shortcut method to retrieve related `MaglaFacility` back-reference. 88 | 89 | Returns 90 | ------- 91 | magla.core.facility.MaglaFacility 92 | The `MaglaFacility` this machine belongs to 93 | """ 94 | r = self.data.record.facility 95 | if not r: 96 | return None 97 | return MaglaEntity.from_record(r) 98 | 99 | @property 100 | def directories(self): 101 | """Shortcut method to retrieve related `MaglaDirectory` back-reference list. 102 | 103 | Returns 104 | ------- 105 | list of magla.core.directory.MaglaDirectory 106 | The `MaglaDirectory` records for this machine 107 | """ 108 | r = self.data.record.directories or [] 109 | return [self.from_record(a) for a in r] 110 | 111 | @property 112 | def contexts(self): 113 | """Shortcut method to retrieve related `MaglaContext` back-reference list. 114 | 115 | Returns 116 | ------- 117 | list of magla.core.context.MaglaContext 118 | The current user `MaglaContext` if any, for this machine 119 | """ 120 | contexts = self.data.record.contexts or [] 121 | return [self.from_record(c) for c in contexts] 122 | -------------------------------------------------------------------------------- /tests/phase_1/test_shot.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.shot`""" 2 | import random 3 | import string 4 | 5 | import pytest 6 | from magla.core.shot import MaglaShot 7 | from magla.test import MaglaEntityTestFixture 8 | from magla.utils import random_string 9 | 10 | 11 | class TestShot(MaglaEntityTestFixture): 12 | 13 | _repr_string = "" 14 | 15 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("Shot")) 16 | def seed_shot(self, request, entity_test_fixture): 17 | data, expected_result = request.param 18 | yield MaglaShot(data) 19 | 20 | def test_can_update_name(self, seed_shot): 21 | random_shot_name = random_string(string.ascii_letters, 6) 22 | seed_shot.data.name = random_shot_name 23 | seed_shot.data.push() 24 | shot_name = MaglaShot(id=seed_shot.id).name 25 | self.reset(seed_shot) 26 | assert shot_name == random_shot_name 27 | 28 | def test_can_update_otio(self, seed_shot): 29 | random_otio_name = random_string(string.ascii_letters, 10) 30 | seed_shot.data.otio.name = random_otio_name 31 | seed_shot.data.push() 32 | otio_name = MaglaShot(id=seed_shot.id).otio.name 33 | self.reset(seed_shot) 34 | assert otio_name == random_otio_name 35 | 36 | def test_can_update_track_index(self, seed_shot): 37 | random_track_index = random.randint(0, 50) 38 | seed_shot.data.track_index = random_track_index 39 | seed_shot.data.push() 40 | track_index = MaglaShot(id=seed_shot.id).track_index 41 | self.reset(seed_shot) 42 | assert track_index == random_track_index 43 | 44 | def test_can_update_start_frame_in_parent(self, seed_shot): 45 | random_start_frame_in_parent = random.randint(0, 650000) 46 | seed_shot.data.start_frame_in_parent = random_start_frame_in_parent 47 | seed_shot.data.push() 48 | start_frame_in_parent = MaglaShot(id=seed_shot.id).start_frame_in_parent 49 | self.reset(seed_shot) 50 | assert start_frame_in_parent == random_start_frame_in_parent 51 | 52 | def test_can_retrieve_directory(self, seed_shot): 53 | backend_data = seed_shot.directory.dict() 54 | seed_data = self.get_seed_data("Directory", seed_shot.directory.id-1) 55 | assert backend_data == seed_data 56 | 57 | def test_can_retrieve_project(self, seed_shot): 58 | backend_data = seed_shot.project.dict() 59 | seed_data = self.get_seed_data("Project", seed_shot.project.id-1) 60 | assert backend_data == seed_data 61 | 62 | def test_can_retrieve_versions(self, seed_shot): 63 | backend_data = seed_shot.versions[0].dict(otio_as_dict=True) 64 | from_seed_data = self.get_seed_data("ShotVersion", seed_shot.versions[0].id-1) 65 | assert backend_data == from_seed_data 66 | 67 | def test_can_retrieve_version_by_num(self, seed_shot): 68 | backend_data = seed_shot.version(0).dict(otio_as_dict=True) 69 | from_seed_data = self.get_seed_data("ShotVersion", seed_shot.versions[0].id-1) 70 | assert backend_data == from_seed_data 71 | 72 | def test_can_generate_full_name(self, seed_shot): 73 | assert seed_shot.full_name == "{}_{}".format(seed_shot.project.name, seed_shot.name) 74 | 75 | def test_can_retrieve_latest_num(self, seed_shot): 76 | seed_data, expected_result = self.get_seed_data("ShotVersion")[-1] 77 | assert (seed_shot.latest_num == seed_data["num"]) == expected_result 78 | 79 | def test_can_retrieve_latest(self, seed_shot): 80 | seed_data, expected_result = self.get_seed_data("ShotVersion")[-1] 81 | backend_data = seed_shot.latest().dict(otio_as_dict=True) 82 | assert (backend_data == seed_data) == expected_result 83 | 84 | def test_object_string_repr(self, seed_shot): 85 | print(seed_shot) 86 | assert str(seed_shot) == self._repr_string.format( 87 | this=seed_shot 88 | ) 89 | -------------------------------------------------------------------------------- /magla/core/context.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Contexts connect `MaglaUser` to `MaglaMachine` as well as define an active `MaglaAssignment` 3 | 4 | The context is intended to be directly interacted with by the user. If for example, the user has 5 | multiple active assignments then the context is where the 'current' assignment can be set so that 6 | context-awareness can be achieved. 7 | 8 | Additionally the context is where we keep track of which machine the user is currently using - 9 | necessary for correct `MaglaDirectory` retrieval. 10 | """ 11 | import getpass 12 | import logging 13 | import os 14 | 15 | from ..db.context import Context 16 | from .data import MaglaData 17 | from .entity import MaglaEntity 18 | from .errors import MaglaError 19 | from .user import MaglaUser 20 | 21 | 22 | class MaglaContextError(MaglaError): 23 | """An error accured preventing MaglaContext to continue.""" 24 | 25 | 26 | class MaglaContext(MaglaEntity): 27 | """Provide an interface for manipulating `contexts` tables.""" 28 | __schema__ = Context 29 | 30 | def __init__(self, data=None, **kwargs): 31 | """Initialize with given data. 32 | 33 | Parameters 34 | ---------- 35 | data : dict, optional 36 | Data to query for matching backend record 37 | """ 38 | super(MaglaContext, self).__init__(data or dict(kwargs)) 39 | 40 | @property 41 | def id(self): 42 | """Retrieve id from data. 43 | 44 | Returns 45 | ------- 46 | int 47 | Postgres column id 48 | """ 49 | return self.data.id 50 | 51 | # SQAlchemy relationship back-references 52 | @property 53 | def machine(self): 54 | """Retrieve related `MaglaMachine` back-reference. 55 | 56 | Returns 57 | ------- 58 | magla.core.machine.MaglaMachine 59 | The `MaglaMachine` associated with this context 60 | """ 61 | r = self.data.record.machine 62 | if not r: 63 | return None 64 | return MaglaEntity.from_record(r) 65 | 66 | @property 67 | def user(self): 68 | """Retrieve related `MaglaUser` back-reference. 69 | 70 | Returns 71 | ------- 72 | magla.core.user.MaglaUser 73 | The `MaglaUser` associated to this context 74 | """ 75 | r = self.data.record.user 76 | if not r: 77 | return None 78 | return MaglaEntity.from_record(r) 79 | 80 | @property 81 | def assignment(self): 82 | """Retrieve related `MaglaAssignment` back-reference. 83 | 84 | Returns 85 | ------- 86 | magla.core.assignment.MaglaAssignment 87 | The `MaglaAssignment` associated to this context 88 | """ 89 | r = self.data.record.assignment 90 | if not r: 91 | return None 92 | return MaglaEntity.from_record(r) 93 | 94 | # MaglaContext-specific methods ________________________________________________________________ 95 | @property 96 | def project(self): 97 | """Shortcut method to retrieve related `MaglaProject` back-reference. 98 | 99 | Returns 100 | ------- 101 | magla.core.project.MaglaProject 102 | The related `MaglaProject` 103 | """ 104 | if not self.assignment: 105 | return None 106 | return self.assignment.shot_version.shot.project 107 | 108 | @property 109 | def shot(self): 110 | """Shortcut method to retrieve related `MaglaShot` back-reference. 111 | 112 | Returns 113 | ------- 114 | magla.core.shot.MaglaShot 115 | The related `MaglaShot` 116 | """ 117 | if not self.assignment: 118 | return None 119 | return self.assignment.shot_version.shot 120 | 121 | @property 122 | def shot_version(self): 123 | """Shortcut method to retrieve related `MaglaShotVersion` back-reference. 124 | 125 | Returns 126 | ------- 127 | magla.core.shot_version.MaglaShotVersion 128 | The related `MaglaShotVersion` 129 | """ 130 | if not self.assignment: 131 | return None 132 | return self.assignment.shot_version 133 | -------------------------------------------------------------------------------- /tests/phase_1/test_project.py: -------------------------------------------------------------------------------- 1 | """Testing for `magla.core.seed_project`""" 2 | import os 3 | import string 4 | import tempfile 5 | 6 | import opentimelineio as otio 7 | import pytest 8 | from magla.core.project import MaglaProject 9 | from magla.test import MaglaEntityTestFixture 10 | from magla.utils import random_string, otio_to_dict, otio_to_dict 11 | 12 | 13 | class TestProject(MaglaEntityTestFixture): 14 | 15 | _repr_string = "" 16 | 17 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("Project")) 18 | def seed_project(self, request, entity_test_fixture): 19 | data, expected_result = request.param 20 | yield MaglaProject(data) 21 | 22 | def test_can_update_name(self, seed_project): 23 | random_name = random_string(string.ascii_letters, 10) 24 | seed_project.data.name = random_name 25 | seed_project.data.push() 26 | name = MaglaProject(id=seed_project.id).name 27 | self.reset(seed_project) 28 | assert name == random_name 29 | 30 | def test_can_update_directory(self, seed_project): 31 | new_id = 3 32 | seed_project.data.directory_id = new_id 33 | seed_project.data.push() 34 | directory = MaglaProject(id=seed_project.id).directory 35 | self.reset(seed_project) 36 | assert directory.dict() == self.get_seed_data("Directory", new_id-1) 37 | 38 | def test_can_update_settings(self, seed_project): 39 | dummy_settings = { 40 | "setting1": "value1", 41 | "setting2": 2, 42 | "setting3": { 43 | "sub_setting1": "sub_value1", 44 | "sub_setting2": 2 45 | } 46 | } 47 | seed_project.data.settings = dummy_settings 48 | seed_project.data.push() 49 | settings = MaglaProject(id=seed_project.id).settings 50 | self.reset(seed_project) 51 | assert settings == dummy_settings 52 | 53 | def test_can_retrieve_shots(self, seed_project): 54 | backend_data = seed_project.shots[0].dict(otio_as_dict=True) 55 | seed_data = self.get_seed_data("Shot", seed_project.shots[0].id-1) 56 | assert len(seed_project.shots) == 1 and backend_data == seed_data 57 | 58 | def test_can_retrieve_tool_configs(self, seed_project): 59 | backend_data = seed_project.tool_configs[0].dict() 60 | seed_data = self.get_seed_data("ToolConfig", seed_project.tool_configs[0].id-1) 61 | assert len(seed_project.tool_configs) == 1 and backend_data == seed_data 62 | 63 | def test_can_retrieve_otio(self, seed_project): 64 | assert otio_to_dict(seed_project.otio) == self.get_seed_data("Timeline", seed_project.timeline.id-1)["otio"] 65 | 66 | def test_can_build_timeline(self, seed_project): 67 | timeline = seed_project.build_timeline(seed_project.shots) 68 | timeline_clip_otio = otio_to_dict(timeline.otio.tracks[0][0]) 69 | shot_otio = otio_to_dict(seed_project.shots[0].otio) 70 | assert len(timeline.otio.tracks) == 1 \ 71 | and len(timeline.otio.tracks[0]) == 1 \ 72 | and timeline_clip_otio == shot_otio 73 | 74 | def test_can_retrieve_shot_by_full_name(self, seed_project): 75 | seed_data = self.get_seed_data("Shot", seed_project.shots[0].id-1) 76 | backend_data = seed_project.shot(seed_data["name"]).dict(otio_as_dict=True) 77 | assert backend_data == seed_data 78 | 79 | def test_can_retrieve_tool_config_by_tool_version_id(self, seed_project): 80 | tool_config = seed_project.tool_config(1) 81 | assert tool_config.dict() == self.get_seed_data("ToolConfig", tool_config.id-1) 82 | 83 | def test_can_export_otio_to_temp_directory(self, seed_project): 84 | destination = os.path.join(tempfile.gettempdir(), "magla_otio_export_test.otio") 85 | seed_project.export_otio(destination, seed_project.shots) 86 | assert otio.adapters.from_filepath(destination) 87 | 88 | def test_can_retrieve_timeline(self, seed_project): 89 | backend_data = seed_project.timeline.dict(otio_as_dict=True) 90 | seed_data = self.get_seed_data("Timeline", seed_project.timeline.id-1) 91 | assert backend_data == seed_data 92 | 93 | def test_object_string_repr(self, seed_project): 94 | assert str(seed_project) == self._repr_string.format( 95 | this=seed_project 96 | ) 97 | -------------------------------------------------------------------------------- /magla/core/tool_version.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A class to manage properties and behaviors of, as well as launch `MaglaToolVersion`'s 3 | 4 | TODO: Implement `MaglaExtension` object/records to replace the `file_extension` property 5 | """ 6 | import getpass 7 | import logging 8 | import os 9 | 10 | from ..db.tool_version import ToolVersion 11 | from .data import MaglaData 12 | from .entity import MaglaEntity 13 | from .errors import MaglaError 14 | 15 | 16 | class MaglaToolVersionError(MaglaError): 17 | """An error accured preventing MaglaToolVersion to continue.""" 18 | 19 | 20 | class MaglaToolVersion(MaglaEntity): 21 | """Provide an interface to manipulate behavior settings at the tool-version level.""" 22 | __schema__ = ToolVersion 23 | 24 | def __init__(self, data=None, **kwargs): 25 | """Initialize with given data. 26 | 27 | Parameters 28 | ---------- 29 | data : dict 30 | Data to query for matching backend record 31 | """ 32 | super(MaglaToolVersion, self).__init__(data or dict(kwargs)) 33 | 34 | def __repr__(self): 35 | return "".format(this=self) 36 | 37 | def __str__(self): 38 | return self.__repr__() 39 | 40 | @property 41 | def id(self): 42 | """Retrieve id from data. 43 | 44 | Returns 45 | ------- 46 | int 47 | Postgres column id 48 | """ 49 | return self.data.id 50 | 51 | @property 52 | def string(self): 53 | """Retrieve string from data. 54 | 55 | Returns 56 | ------- 57 | str 58 | The version-string representation of this tool version 59 | """ 60 | return self.data.string 61 | 62 | @property 63 | def file_extension(self): 64 | """Retrieve file_extension from data. 65 | 66 | Returns 67 | ------- 68 | str 69 | The file-extension to use when searching for openable project files. 70 | """ 71 | return self.data.file_extension # TODO: use MaglaFileType instead 72 | 73 | # SQAlchemy relationship back-references 74 | @property 75 | def tool(self): 76 | """Shortcut method to retrieve related `MaglaTool` back-reference. 77 | 78 | Returns 79 | ------- 80 | magla.core.tool.MaglaTool 81 | The `MaglaTool` for this tool-version 82 | """ 83 | r = self.data.record.tool 84 | return MaglaEntity.from_record(r) 85 | 86 | @property 87 | def tool_config(self): 88 | """Shortcut method to retrieve related `MaglaToolConfig` back-reference. 89 | 90 | Returns 91 | ------- 92 | magla.core.tool.MaglaToolConfig 93 | The `MaglaToolConfig` for this tool-version 94 | """ 95 | r = self.data.record.tool_config 96 | return MaglaEntity.from_record(r) 97 | 98 | @property 99 | def installations(self): 100 | """Shortcut method to retrieve related `MaglaToolVersionInstallation` back-reference list. 101 | 102 | Returns 103 | ------- 104 | list of magla.core.tool_version.MaglaToolVersionInstallations 105 | A list of `MaglaToolVersionInstallations` objects associated to this tool-version 106 | """ 107 | return [self.from_record(a) for a in self.data.record.installations] 108 | 109 | # MaglaToolVersion-specific methods ____________________________________________________________ 110 | @property 111 | def full_name(self): 112 | """Generate a name for this tool-version using the shot name and version number. 113 | 114 | Returns 115 | ------- 116 | str 117 | 'shot_name_vXXX' 118 | """ 119 | return "{this.tool.name}_{this.string}".format(this=self) 120 | 121 | def installation(self, machine_id): 122 | """Retrieve a specific installation of this tool on the given machine 123 | 124 | Parameters 125 | ---------- 126 | machine_id : int 127 | The ID of the `MaglaMachine` to search for a tool-installation 128 | 129 | Returns 130 | ------- 131 | `MaglaToolInstallation` or None 132 | The `MaglaToolInstallation` object on specified machine 133 | """ 134 | matches = [ 135 | i for i in self.installations if i.directory.machine.id == machine_id] 136 | if matches: 137 | matches = matches[0] 138 | else: 139 | matches = None 140 | return matches 141 | 142 | def start(self): 143 | self.tool.start(self.id) 144 | -------------------------------------------------------------------------------- /magla/core/shot_version.py: -------------------------------------------------------------------------------- 1 | """Shot versions are a single collection of `subsets`, and their generated representation.""" 2 | import opentimelineio as otio 3 | 4 | from ..db.shot_version import ShotVersion 5 | from .entity import MaglaEntity 6 | from .errors import MaglaError 7 | 8 | 9 | class MaglaShotVersionError(MaglaError): 10 | """An error accured preventing MaglaShotVersion to continue.""" 11 | 12 | 13 | class MaglaShotVersion(MaglaEntity): 14 | """Provide an interface to the `subsets` of this shot version and its filesystem details.""" 15 | __schema__ = ShotVersion 16 | 17 | def __init__(self, data=None, **kwargs): 18 | """Initialize with given data. 19 | 20 | Parameters 21 | ---------- 22 | data : dict 23 | Data to query for matching backend record 24 | """ 25 | super(MaglaShotVersion, self).__init__(data or dict(kwargs)) 26 | 27 | def __repr__(self): 28 | return "". \ 29 | format(this=self) 30 | 31 | def __str__(self): 32 | return self.__repr__() 33 | 34 | @property 35 | def id(self): 36 | """Retrieve id from data. 37 | 38 | Returns 39 | ------- 40 | int 41 | Postgres column id 42 | """ 43 | return self.data.id 44 | 45 | @property 46 | def num(self): 47 | """Retrieve num from data. 48 | 49 | Returns 50 | ------- 51 | str 52 | Version number of this shot version 53 | """ 54 | return self.data.num 55 | 56 | @property 57 | def otio(self): 58 | """Retrieve otio from data. 59 | 60 | Returns 61 | ------- 62 | opentimelineio.schema.ImageSequenceReference 63 | The external reference to the representation of this shot version 64 | """ 65 | return self.data.otio 66 | 67 | # SQAlchemy relationship back-references 68 | @property 69 | def directory(self): 70 | """Shortcut method to retrieve related `MaglaDirectory` back-reference. 71 | 72 | Returns 73 | ------- 74 | magla.core.directory.MaglaDirectory 75 | The `MaglaDirectory` for this shot version 76 | """ 77 | r = self.data.record.directory 78 | if not r: 79 | return None 80 | return MaglaEntity.from_record(r) 81 | 82 | @property 83 | def assignment(self): 84 | """Shortcut method to retrieve related `MaglaAssignment` back-reference. 85 | 86 | Returns 87 | ------- 88 | magla.core.assignment.MaglaAssignment 89 | The `MaglaAssignment` which spawned this version 90 | """ 91 | r = self.data.record.assignment 92 | if not r: 93 | return None 94 | return MaglaEntity.from_record(r) 95 | 96 | @property 97 | def shot(self): 98 | """Shortcut method to retrieve related `MaglaShot` back-reference. 99 | 100 | Returns 101 | ------- 102 | magla.core.shot.MaglaShot 103 | The `MaglaShot` this version is associated to 104 | """ 105 | r = self.data.record.shot 106 | if not r: 107 | return None 108 | return MaglaEntity.from_record(r) 109 | 110 | # MaglaShot-specific methods ___________________________________________________________________ 111 | @property 112 | def project(self): 113 | """Shortcut method to retrieve related `MaglaProject` back-reference. 114 | 115 | Returns 116 | ------- 117 | magla.core.project.MaglaProject 118 | The `MaglaProject` this shot version belongs to 119 | """ 120 | r = self.data.record.shot.project 121 | if not r: 122 | return None 123 | return MaglaEntity.from_record(r) 124 | 125 | @property 126 | def name(self): 127 | """Generate a name for this shot version by combining the shot name with version num. 128 | 129 | Returns 130 | ------- 131 | str 132 | Name of the shot combined with the version number 133 | """ 134 | return "{shot_name}_v{version_num:03d}".format( 135 | shot_name=self.shot.name, 136 | version_num=self.num) 137 | 138 | @property 139 | def full_name(self): 140 | """Generate a name prepended with project name. 141 | 142 | Returns 143 | ------- 144 | str 145 | The name of shit shot version prepended with the project name 146 | 147 | Example: 148 | ``` 149 | project_name_shot_name_v001 150 | ``` 151 | """ 152 | return "{project_name}_{shot_version_name}".format( 153 | project_name=self.shot.project.name, 154 | shot_version_name=self.name) 155 | -------------------------------------------------------------------------------- /magla/core/user.py: -------------------------------------------------------------------------------- 1 | """Users are associated with operating system user accounts.""" 2 | import getpass 3 | 4 | from ..db.user import User 5 | from .entity import MaglaEntity 6 | from .errors import MaglaError 7 | 8 | 9 | class MaglaUserError(MaglaError): 10 | """An error accured preventing MaglaUser to continue.""" 11 | 12 | 13 | class MaglaUser(MaglaEntity): 14 | """Provide interface to user details and privileges.""" 15 | __schema__ = User 16 | 17 | def __init__(self, data=None, **kwargs): 18 | """Initialize with given data. 19 | 20 | Parameters 21 | ---------- 22 | data : dict 23 | Data to query for matching backend record 24 | """ 25 | if (not data and not kwargs): 26 | data = {"nickname": MaglaUser.current()} 27 | elif isinstance(data, str): 28 | data = {"nickname": data} 29 | 30 | super(MaglaUser, self).__init__(data or dict(kwargs)) 31 | 32 | @property 33 | def id(self): 34 | """Retrieve id from data. 35 | 36 | Returns 37 | ------- 38 | int 39 | Postgres column id 40 | """ 41 | return self.data.id 42 | 43 | @property 44 | def nickname(self): 45 | """Retrieve nickname from data. 46 | 47 | Returns 48 | ------- 49 | str 50 | Unique nickname for this user 51 | """ 52 | return self.data.nickname 53 | 54 | @property 55 | def first_name(self): 56 | """Retrieve first_name from data. 57 | 58 | Returns 59 | ------- 60 | str 61 | first_name of this user 62 | """ 63 | return self.data.first_name 64 | 65 | @property 66 | def last_name(self): 67 | """Retrieve last_name from data. 68 | 69 | Returns 70 | ------- 71 | str 72 | last_name of this user 73 | """ 74 | return self.data.last_name 75 | 76 | @property 77 | def email(self): 78 | """Retrieve email from data. 79 | 80 | Returns 81 | ------- 82 | str 83 | email of this user 84 | """ 85 | return self.data.email 86 | 87 | # SQAlchemy relationship back-references 88 | @property 89 | def context(self): 90 | """Shortcut method to retrieve related `MaglaContext` back-reference. 91 | 92 | Returns 93 | ------- 94 | magla.core.context.MaglaContext 95 | The unique `MaglaContext` for this user. 96 | """ 97 | r = self.data.record.context 98 | return MaglaEntity.from_record(r) 99 | 100 | @property 101 | def assignments(self): 102 | """Shortcut method to retrieve related `MaglaAssignment` back-reference list. 103 | 104 | Returns 105 | ------- 106 | list of magla.core.assignment.MaglaAssignment 107 | The currently active `MaglaAssignment` for this user if one is set 108 | """ 109 | return [self.from_record(a) for a in self.data.record.assignments] 110 | 111 | @property 112 | def directories(self): 113 | """Shortcut method to retrieve related `MaglaDirectory` back-reference list. 114 | 115 | Returns 116 | ------- 117 | list of magla.core.directory.MaglaDirectory 118 | A list of private `MaglaDirectory` objects for this user. These could be custom local 119 | working directories, or sandbox-like directories - any place on a filesystem where 120 | `magla` functionality is desired. 121 | """ 122 | return [self.from_record(a) for a in self.data.record.directories] 123 | 124 | @property 125 | def timelines(self): 126 | """Shortcut method to retrieve related `MaglaTimeline` back-reference list. 127 | 128 | Returns 129 | ------- 130 | magla.core.timeline.MaglaTimeline 131 | A list of private `MaglaTimeline` objects saved by this user 132 | """ 133 | return [self.from_record(a) for a in self.data.record.timelines] 134 | 135 | # MaglaUser-specific methods ________________________________________________________________ 136 | @staticmethod 137 | def current(): 138 | """Retrieve a `MaglaUser` object for the currently logged in user. 139 | 140 | Returns 141 | ------- 142 | magla.core.user.MaglaUser 143 | The current `MaglaUser` 144 | """ 145 | return getpass.getuser() 146 | 147 | def directory(self, label): 148 | """Retrieve one of this user's private `MaglaDirectory` objects by label. 149 | 150 | Parameters 151 | ---------- 152 | label : str 153 | The label for the directory to retrieve 154 | 155 | Returns 156 | ------- 157 | magla.core.directory.MaglaDirectory 158 | The retrieved `MaglaDirectory` 159 | """ 160 | for d in self.directories: 161 | if d.label == label: 162 | return d 163 | return None 164 | -------------------------------------------------------------------------------- /magla/core/config.py: -------------------------------------------------------------------------------- 1 | """Basic config reader for future config loading implementations.""" 2 | import json 3 | import os 4 | 5 | import yaml 6 | 7 | from .errors import ConfigPathError, ConfigReadError 8 | 9 | 10 | class MaglaConfig(object): 11 | """Provide an interface to multiple types of config filetypes. 12 | 13 | Supported types: 14 | ---------------- 15 | - json 16 | - yaml 17 | """ 18 | _loaders = { 19 | "json": json.load, 20 | "yaml": yaml.safe_load 21 | } 22 | _writers = { 23 | "json": json.dumps, 24 | "yaml": yaml.dump 25 | } 26 | 27 | def __init__(self, path): 28 | """Initialize with config path. 29 | 30 | Parameters 31 | ---------- 32 | path : str 33 | Path to the config file 34 | """ 35 | self._path = path 36 | self._config = self.load() 37 | 38 | @property 39 | def path(self): 40 | """Retrieve path to current instance's config json. 41 | 42 | Returns 43 | ------- 44 | str 45 | The path to the associated config file 46 | """ 47 | return self._path 48 | 49 | def clear(self): 50 | """Reset the prefs json to an empty dict and save.""" 51 | self.save({}) 52 | 53 | def get(self, key, default=None): 54 | """Get value by key from current instance's config. 55 | 56 | Parameters 57 | ---------- 58 | key : str 59 | The key to retrieve 60 | default : *, optional 61 | Value to return if key was not found, by default None 62 | 63 | Returns 64 | ------- 65 | * 66 | Value of given key 67 | """ 68 | return self._config.get(key, default) 69 | 70 | def load(self): 71 | """Retrieve the contents of config.json and set to the current instance. 72 | 73 | Returns 74 | ------- 75 | dict 76 | Python dict version of the loaded config file 77 | 78 | Raises 79 | ------ 80 | ConfigReadError 81 | Thrown if the given config path was unreadable 82 | ConfigPathError 83 | Thrown if an invalid config path was given 84 | """ 85 | config_dict = {} 86 | loader = self._get_loader() 87 | try: 88 | with open(self._path, "r") as config_fo: 89 | config_dict = loader(config_fo) 90 | except (FileNotFoundError, PermissionError, json.decoder.JSONDecodeError) as err: 91 | if isinstance(err, json.decoder.JSONDecodeError): 92 | raise ConfigReadError(err) 93 | raise ConfigPathError(err) 94 | 95 | self._config = config_dict 96 | 97 | return self._config 98 | 99 | def save(self, config_dict): 100 | """Apply given config_dict to disk and update isntance dict. 101 | 102 | Parameters 103 | ---------- 104 | config_dict : dict 105 | The config dict to save to the loaded config file 106 | 107 | Returns 108 | ------- 109 | dict 110 | The saved config dict 111 | 112 | Raises 113 | ------ 114 | ConfigReadError 115 | Thrown if config file was unreadable 116 | ConfigPathError 117 | Thrown if path to the config file is invalid 118 | """ 119 | writer = self._get_writer() 120 | try: 121 | with open(self._path, "w+") as config_fo: 122 | config_fo.write(writer(config_dict)) 123 | except (FileNotFoundError, PermissionError, json.decoder.JSONDecodeError) as err: 124 | if isinstance(err, json.decoder.JSONDecodeError): 125 | raise ConfigReadError(err) 126 | raise ConfigPathError(err) 127 | 128 | self._config = config_dict 129 | 130 | return self._config 131 | 132 | def update(self, new_config_dict): 133 | """Update the instance dict. 134 | 135 | Parameters 136 | ---------- 137 | new_config_dict : dict 138 | dictionary of preferences to update. 139 | 140 | Returns 141 | ------- 142 | dict 143 | Dict of the config. 144 | """ 145 | self._config.update(new_config_dict) 146 | return self._config 147 | 148 | def dict(self): 149 | """Retrieve config contents as a dict. 150 | 151 | Returns 152 | ------- 153 | dict 154 | dict containing the contents of given config file. 155 | """ 156 | return self._config 157 | 158 | def _get_loader(self): 159 | """Retrieve the correct adapter for loading. 160 | 161 | Returns 162 | ------- 163 | function 164 | The filetype-specific function to be used to load contents. 165 | """ 166 | config_type = os.path.splitext(self._path)[1].replace(".", "") 167 | return self._loaders[config_type] 168 | 169 | def _get_writer(self): 170 | """Retrieve the correct adapter for writing. 171 | 172 | Returns 173 | ------- 174 | function 175 | The filetype-specific function to be used to write to the given config file. 176 | """ 177 | config_type = os.path.splitext(self._path)[1].replace(".", "") 178 | return self._writers[config_type] 179 | -------------------------------------------------------------------------------- /magla/core/entity.py: -------------------------------------------------------------------------------- 1 | """Entity is the root class connecting `core` objects to their backend equivilent. """ 2 | from pprint import pformat 3 | 4 | from ..db import ORM 5 | from ..utils import otio_to_dict, record_to_dict 6 | from .data import MaglaData 7 | from .errors import MaglaError 8 | 9 | 10 | class MaglaEntityError(MaglaError): 11 | """Base exception class.""" 12 | 13 | 14 | class BadArgumentError(MaglaEntityError): 15 | """An invalid argument was given.""" 16 | 17 | 18 | class MaglaEntity(object): 19 | """General wrapper for anything in `magla` that persists in the backend. 20 | 21 | This class should be subclassed and never instantiated on its own. 22 | """ 23 | _ORM = ORM 24 | _orm = None 25 | 26 | def __init__(self, data=None, **kwargs): 27 | """Initialize with model definition, data, and supplimental kwargs as key-value pairs. 28 | 29 | Parameters 30 | ---------- 31 | model : sqlalchemy.ext.declarative 32 | The associated model for this subentity. 33 | data : dict 34 | The data to use in the query to retrieve from backend. 35 | 36 | Raises 37 | ------ 38 | BadArgumentError 39 | Invalid argument was given which prevents instantiation. 40 | """ 41 | self.connect() 42 | if isinstance(data, dict): 43 | data = MaglaData(self.__schema__, data, self.orm.session) 44 | if not isinstance(data, MaglaData): 45 | raise BadArgumentError("First argument must be a MaglaData object or python dict. \n" 46 | "Received: \n\t{received} ({type_received})".format( 47 | received=data, 48 | type_received=type(data))) 49 | 50 | self._data = data 51 | 52 | def __str__(self): 53 | """Overwrite the default string representation. 54 | 55 | Returns 56 | ------- 57 | str 58 | Display entity type with list of key/values contained in its data. 59 | 60 | example: 61 | ``` 62 | 63 | ``` 64 | """ 65 | data = self.data.dict() 66 | id_ = self.id 67 | entity_type = self.data._schema.__entity_name__ 68 | keys_n_vals = [] 69 | sorted_keys = list(data.keys()) 70 | sorted_keys.sort() 71 | for key in sorted_keys: 72 | if key == "id": 73 | continue 74 | keys_n_vals.append("{key}={val}".format(key=key, val=data[key])) 75 | 76 | return "<{entity_type} {id}: {keys_n_vals}>".format( 77 | entity_type=entity_type, 78 | id=id_, 79 | keys_n_vals=", ".join(keys_n_vals)) 80 | 81 | def __repr__(self): 82 | return self.__str__() 83 | 84 | @classmethod 85 | def from_record(cls, record_obj, **kwargs): 86 | """Instantiate a sub-entity matching the properties of given model object. 87 | 88 | Parameters 89 | ---------- 90 | record_obj : sqlalchemy.ext.declarative.api.Base 91 | A `SQLAlchemy` mapped entity model containing data directly from backend 92 | 93 | Returns 94 | ------- 95 | magla.core.entity.MaglaEntity 96 | Sub-classed `MaglaEntity` object (defined in 'magla/db/') 97 | 98 | Raises 99 | ------ 100 | BadArgumentError 101 | An invalid argument was given. 102 | """ 103 | if not record_obj: 104 | return None 105 | # get modeul from magla here 106 | entity_type = cls.type(record_obj.__entity_name__) 107 | data = record_to_dict(record_obj, otio_as_dict=True) 108 | return entity_type(data, **kwargs) 109 | 110 | def dict(self, otio_as_dict=True): 111 | """Return dictionary representation of this entity. 112 | 113 | Returns 114 | ------- 115 | dict 116 | A dictionary representation of this entity with all current properties. 117 | """ 118 | if otio_as_dict: 119 | return otio_to_dict(self.data.dict()) 120 | return self.data.dict() 121 | 122 | def pprint(self): 123 | """Return a 'pretty-printed' string representation of this entity.""" 124 | return pformat(self.dict(), width=1) 125 | 126 | @property 127 | def data(self): 128 | """Retrieve `MaglaData` object for this entity. 129 | 130 | Returns 131 | ------- 132 | magla.core.data.MaglaData 133 | The `MaglaData` object containing all data for this entity as well as a direct 134 | connection to related backend table. 135 | """ 136 | return self._data 137 | 138 | @property 139 | def orm(self): 140 | """Retrieve `MaglaORM` object used for backend interactions 141 | 142 | Returns 143 | ------- 144 | `magla.db.orm.MaglaORM` 145 | The backend interface object all entities communicate through. 146 | """ 147 | return self._orm 148 | 149 | @classmethod 150 | def type(cls, name): 151 | """Return the class definition of the current entity type. 152 | 153 | Parameters 154 | ---------- 155 | name : str 156 | The name of the entity to retrieve 157 | 158 | Returns 159 | ------- 160 | magla.core.entity.Entity 161 | The sub-classed entity (defined in 'magla/db/') 162 | """ 163 | return cls.__types__[name] 164 | 165 | @classmethod 166 | def connect(cls): 167 | """Instantiate the `MaglaORM` object.""" 168 | if not cls._orm: 169 | cls._orm = cls._ORM() 170 | cls._orm.init() 171 | -------------------------------------------------------------------------------- /magla/test.py: -------------------------------------------------------------------------------- 1 | """The `MaglaEntityTestFixture` is an interface for managing and accessing test data and db.""" 2 | import configparser 3 | import os 4 | 5 | from magla import Config, Entity 6 | 7 | 8 | class MaglaTestFixture: 9 | 10 | _seed_data = Config(os.path.join(os.environ["MAGLA_TEST_DIR"], "seed_data.yaml")) 11 | __cp = configparser.ConfigParser() 12 | _machine_test_data = __cp.read( 13 | os.path.join(os.environ["MAGLA_TEST_DIR"], "magla_machine", "machine.ini")) 14 | 15 | @classmethod 16 | def get_seed_data(cls, entity_type, index=None): 17 | """Retrieve either a specific seed data dict or all seed data tuples by entity type. 18 | 19 | Parameters 20 | ---------- 21 | entity_type : str 22 | The `magla` sub-entity type (eg. `Project`, `ToolVersion`) to retrieve seed data for. 23 | index : int, optional 24 | The specific index/id of the seed data instance to retrieve, by default None 25 | 26 | Returns 27 | ------- 28 | list or dict 29 | Either the specific data dict or the entire list of (`data`, `expected_result`) tuples. 30 | """ 31 | if index is not None: 32 | # return seed data dict without `expected_result` for instance specified by index 33 | seed_data_dict = cls._seed_data.load().get(entity_type)[index][0] 34 | return seed_data_dict 35 | else: 36 | # return all seed data tuples for given entity type 37 | seed_data_tup_list = [] 38 | for seed_data_tup in cls._seed_data.load().get(entity_type): 39 | seed_data_dict, expected_result = seed_data_tup 40 | seed_data_tup_list.append([seed_data_dict, True]) 41 | return seed_data_tup_list 42 | 43 | @classmethod 44 | def seed_data(cls, entity_type=None, seed_data_path=None): 45 | if seed_data_path: 46 | seed_data = Config(seed_data_path).load() 47 | else: 48 | seed_data = cls._seed_data.load() 49 | return seed_data.get(entity_type, []) if entity_type else seed_data 50 | 51 | @classmethod 52 | def seed_otio(cls): 53 | return otio.adapters.read_from_file( 54 | os.path.join(os.environ["MAGLA_TEST_DIR"], "test_project.otio")) 55 | 56 | 57 | class MaglaEntityTestFixture(MaglaTestFixture): 58 | """This class should be inherited by your test-cases, then served via startup methods. 59 | 60 | Example instantiation within a `pytest` fixture (conftest.py): 61 | ``` 62 | @pytest.fixture(scope='session') 63 | def entity_test_fixture(): 64 | entity_test_fixture_ = MaglaEntityTestFixture() 65 | # start a new testing session with connection to `magla.orm.MaglaORM.CONFIG["db_name"]` 66 | entity_test_fixture_.start() 67 | # yield the `MaglaEntityTestFixture` object to the test-case 68 | yield entity_test_fixture_ 69 | # end testing session and drop all tables as the tear-down process 70 | entity_test_fixture_.end(drop_tables=True) 71 | ``` 72 | 73 | In `pytest` you have 2 options: 74 | - inherit from `MaglaEntityTestFixture` and access its contents via `self` 75 | - use the yielded object from `conftest` from the `entity_test_fixture` param 76 | 77 | Either way you must yield and, at least include the `entity_test_fixture` param as shown below 78 | or else `pytest` doesn't seem to instantiate it. 79 | 80 | Example test inheriting from `MaglaEntityTestFixture` and including the un-used param: 81 | ``` 82 | class TestUser(MaglaEntityTestFixture): 83 | 84 | @pytest.fixture(scope="class", params=MaglaEntityTestFixture.seed_data("User")) 85 | def seed_user(self, request, entity_test_fixture): 86 | data, expected_result = request.param 87 | yield MaglaUser(data) 88 | 89 | def test_can_update_nickname(self, seed_user): 90 | random_nickname = random_string(string.ascii_letters, 10) 91 | seed_user.data.nickname = random_nickname 92 | seed_user.data.push() 93 | confirmation = MaglaUser(id=seed_user.id) 94 | assert confirmation.nickname == random_nickname 95 | ``` 96 | """ 97 | @classmethod 98 | def create_entity(cls, subentity_type): 99 | # this method is essentially a replacement for `magla.Root` for creation 100 | entity = Entity.type(subentity_type) 101 | seed_data_list = cls._seed_data.load().get(subentity_type, []) 102 | for seed_data_tup in seed_data_list: 103 | data, expected_result = seed_data_tup 104 | # data = utils.otio_to_dict(data) 105 | new_record = entity.__schema__(**data) 106 | Entity._orm.session.add(new_record) 107 | Entity._orm.session.commit() 108 | 109 | @classmethod 110 | def create_all_seed_records(cls, seed_data_path=None): 111 | if seed_data_path: 112 | seed_data = Config(seed_data_path).load() 113 | else: 114 | seed_data = cls._seed_data.load() 115 | for type_ in seed_data: 116 | cls.create_entity(type_) 117 | 118 | @classmethod 119 | def start(cls): 120 | """Modify backend dialect to sqlite for testing, then make connection.""" 121 | Entity.connect() 122 | # for testing, we overwrite `uuid` temporarily with our seed machine data 123 | cls.__magla_machine_data_dir_backup = os.environ["MAGLA_MACHINE_CONFIG_DIR"] 124 | os.environ["MAGLA_MACHINE_CONFIG_DIR"] = os.path.join(os.environ["MAGLA_TEST_DIR"], "magla_machine") 125 | 126 | @classmethod 127 | def end(cls, drop_tables): 128 | Entity._orm.session.close() 129 | if drop_tables: 130 | Entity._orm._drop_all_tables() 131 | os.environ["MAGLA_MACHINE_CONFIG_DIR"] = cls.__magla_machine_data_dir_backup 132 | 133 | @classmethod 134 | def reset(cls, magla_subentity): 135 | sub_entity_type = magla_subentity.__schema__.__entity_name__ 136 | index = magla_subentity.id-1 137 | reset_data = cls.get_seed_data(sub_entity_type, index) 138 | magla_subentity.data.update(reset_data) 139 | magla_subentity.data.push() 140 | magla_subentity.data.pull() 141 | -------------------------------------------------------------------------------- /magla/core/tool_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """A class to manage the execution of tools and log output from their processes.""" 3 | import getpass 4 | import logging 5 | import os 6 | import sys 7 | 8 | from ..db.tool_config import ToolConfig 9 | from .errors import MaglaError 10 | from .data import MaglaData 11 | from .entity import MaglaEntity 12 | 13 | 14 | class MaglaToolConfigError(MaglaError): 15 | """An error accured preventing MaglaToolConfig to continue.""" 16 | 17 | 18 | class MaglaToolConfig(MaglaEntity): 19 | """Manage relationships between `projects`, `tool_versions`, and `directories` tables. 20 | 21 | Primary roles: 22 | - Associate a `Project` with a particular `ToolVersion`. 23 | - Define the directory structure to be used for tool-specific shot version files 24 | 25 | Each `Project` should define one `ToolConfig` for each `Tool` <--> `ToolVersion` designated for 26 | use. A `Directory` is also defined which will be used to auto-create the `ToolVersion`'s 27 | sub-directory-tree whithin the shot directory structure. 28 | """ 29 | __schema__ = ToolConfig 30 | 31 | def __init__(self, data=None, **kwargs): 32 | """Initialize with given data. 33 | 34 | Parameters 35 | ---------- 36 | data : dict 37 | Data to query for matching backend record 38 | """ 39 | super(MaglaToolConfig, self).__init__(data or dict(kwargs)) 40 | 41 | @property 42 | def id(self): 43 | """Retrieve id from data. 44 | 45 | Returns 46 | ------- 47 | int 48 | Postgres column id 49 | """ 50 | return self.data.id 51 | 52 | @property 53 | def env(self): 54 | """Retrieve env from data. 55 | 56 | Returns 57 | ------- 58 | dict 59 | dictionary representing the custom environment to inject when launching the tool 60 | """ 61 | return self.data.env or {} 62 | 63 | @property 64 | def copy_dict(self): 65 | """Retrieve copy_dict from data. 66 | 67 | Returns 68 | ------- 69 | dict 70 | Dictionary containing source and destination paths to be copied to local work folder 71 | """ 72 | return self.data.copy_dict 73 | 74 | # SQAlchemy relationship back-references 75 | @property 76 | def project(self): 77 | """Shortcut method to retrieve related `MaglaProject` back-reference. 78 | 79 | Returns 80 | ------- 81 | magla.core.project.MaglaProject 82 | The `MaglaProject` this tool config belongs to 83 | """ 84 | r = self.data.record.project 85 | return MaglaEntity.from_record(r) 86 | 87 | @property 88 | def tool_version(self): 89 | """Shortcut method to retrieve related `MaglaToolVersion` back-reference. 90 | 91 | Returns 92 | ------- 93 | magla.core.tool_version.MaglaToolVersion 94 | The `MaglaToolVersion` that this tool config is assigned 95 | """ 96 | r = self.data.record.tool_version 97 | return MaglaEntity.from_record(r) 98 | 99 | @property 100 | def directory(self): 101 | """Shortcut method to retrieve related `MaglaDirectory` back-reference. 102 | 103 | Returns 104 | ------- 105 | magla.core.directory.MaglaDirectory 106 | The `MaglaDirectory` definiing how this tool will be represented within shot folders 107 | """ 108 | r = self.data.record.directory 109 | return MaglaEntity.from_record(r) 110 | 111 | # MaglaToolConfig-specific methods __________________________________________________________ 112 | @property 113 | def tool(self): 114 | """Shortcut method to retrieve related `MaglaToolVersion` back-reference. 115 | 116 | Returns 117 | ------- 118 | magla.core.tool_version.MaglaToolVersion 119 | The `MaglaTool` this config is related to 120 | """ 121 | if not self.tool_version: 122 | return None 123 | return self.tool_version.tool 124 | 125 | def build_env(self): 126 | """Generate an environment dict from this tool config. 127 | 128 | Returns 129 | ------- 130 | dict 131 | Dictionary of the environment to use for this tool's launch process 132 | """ 133 | # add the correct version of MagLa API to PYTHONPATH 134 | env_ = dict(os.environ) 135 | 136 | if "PYTHONPATH" not in env_: 137 | env_["PYTHONPATH"] = "" 138 | env_["PYTHONPATH"] += ";{}".format(self.env["PYTHONPATH"]) 139 | if "PATH" not in env_: 140 | env_["PATH"] = "" 141 | # env_["PATH"] += ";{}".format(self.env["PATH"]) 142 | 143 | # add each environment var from toolconfig 144 | env_.update(self.env) 145 | 146 | return env_ 147 | 148 | @classmethod 149 | def from_user_context(cls, tool_id, context): 150 | """Retrieve the `MaglaToolConfig` associated to given `MaglaContext`. 151 | 152 | Parameters 153 | ---------- 154 | tool_id : id 155 | The id of the `MaglaTool` with a related tool config 156 | context : magla.core.context.MaglaContext 157 | The `MaglaContext` which would give us access to project -> tool_config 158 | 159 | Returns 160 | ------- 161 | magla.core.tool_config.MaglaToolConfig 162 | The retrieved `MaglaToolConfig` or None 163 | """ 164 | a = context.assignment 165 | # check for assignment context 166 | if a: 167 | return a.shot_version.shot.project.tool_config(tool_id) 168 | 169 | # check if user has any assignments 170 | if not context.user.assignments: 171 | return None 172 | 173 | # choose an assignment 174 | # TODO: make this based on last opened 175 | a = context.user.assignments[-1] 176 | for a in context.user.assignments: 177 | project_configs = a.shot_version.shot.project.tool_configs 178 | for c in project_configs: 179 | if c.tool.id == tool_id: 180 | return c 181 | return None 182 | -------------------------------------------------------------------------------- /magla/core/tool.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Tools are generic wrappers which give access to `ToolVersions` as well as internal metadata.""" 3 | import getpass 4 | import logging 5 | import os 6 | import subprocess 7 | import sys 8 | from pprint import pformat 9 | 10 | from ..db.tool import Tool 11 | from .entity import MaglaEntity 12 | 13 | 14 | class MaglaTool(MaglaEntity): 15 | """Provide interface for managing tools and their versions.""" 16 | __schema__ = Tool 17 | 18 | def __init__(self, data=None, **kwargs): 19 | """Instantiate with given data. 20 | 21 | Parameters 22 | ---------- 23 | data : dict, optional 24 | Data to query for mathcing backend record, by default None 25 | """ 26 | if isinstance(data, str): 27 | data = {"name": data} 28 | super(MaglaTool, self).__init__(data or dict(kwargs)) 29 | 30 | @property 31 | def id(self): 32 | """Retrieve id from data. 33 | 34 | Returns 35 | ------- 36 | int 37 | Postgres column id 38 | """"" 39 | return self.data.id 40 | 41 | @property 42 | def name(self): 43 | """Retrieve name from data. 44 | 45 | Returns 46 | ------- 47 | str 48 | Name of the tool 49 | """ 50 | return self.data.name 51 | 52 | @property 53 | def description(self): 54 | """Internal description of the tool and it's use-cases within the pipeline. 55 | 56 | Returns 57 | ------- 58 | str 59 | long-form description of the tool for use internally. 60 | """ 61 | return self.data.description 62 | 63 | @property 64 | def versions(self): 65 | """Shortcut method to retrieve related `MaglaToolVersion` back-reference list. 66 | 67 | Returns 68 | ------- 69 | list of magla.core.tool_version.MaglaToolVersion 70 | A list of `MaglaToolVersion` objects associated to this tool 71 | """ 72 | r = self.data.record.versions 73 | if not r: 74 | return [] 75 | return [self.from_record(a) for a in r] 76 | 77 | # MaglaTool-specific methods ________________________________________________________________ 78 | @property 79 | def latest(self): 80 | """Retrieve the latest `MaglaToolVersion` for this tool currently. 81 | 82 | Returns 83 | ------- 84 | magla.core.tool_version.MaglaToolVersion 85 | The latest `MaglaToolVersion` currently for this shot 86 | """ 87 | if not self.versions: 88 | return None 89 | return self.versions[-1] 90 | 91 | @property 92 | def default_version(self): 93 | """TODO: Retrieve the default version as defined in `project.settings` 94 | 95 | Returns 96 | ------- 97 | magla.core.tool_version.MaglaToolVersion 98 | The default `MaglaToolVersion` to be used when none is designated 99 | """ 100 | return self.latest 101 | 102 | def start(self, tool_version_id=None, tool_config=None, user=None, assignment=None, *args): 103 | """Start the given `MaglatoolVersion` with either given context or inferred context. 104 | 105 | Parameters 106 | ---------- 107 | tool_version_id : int, optional 108 | Id of the `MaglaToolVersion` to launch specifically, by default None 109 | tool_config : `MaglaToolConfig`, optional 110 | The `MaglaToolConfig` instance to use for context, by default None 111 | user : `MaglaUser`, optional 112 | The `MaglaUser` whos context to use when launching, by default None 113 | assignment : `MaglaAssignment`, optional 114 | The `MaglaAssignment` to use for context, by default None 115 | 116 | Returns 117 | ------- 118 | subprocess.Popen 119 | The running subprocess object 120 | """ 121 | # establish user whos context to use 122 | user = user or MaglaEntity.type("User")() 123 | 124 | # establish which tool config if any, to use 125 | tool_config = tool_config \ 126 | or MaglaEntity.type("ToolConfig").from_user_context(self.id, user.context) 127 | 128 | # if no tool config can be established, start tool in vanilla mode. 129 | tool_version = self.latest 130 | if not tool_config: 131 | machine = MaglaEntity.type("Machine")() 132 | return subprocess.Popen([tool_version.installation(machine.id).directory.bookmarks["exe"]]) 133 | 134 | # establish which tool version to launch 135 | if tool_version_id: 136 | tool_version = MaglaEntity.type("ToolVersion")(id=tool_version_id) 137 | elif tool_config: 138 | tool_version = tool_config.tool_version 139 | 140 | # establish environment to inject 141 | env_ = tool_config.build_env() 142 | 143 | # establish path to tool executeable 144 | tool_exe = tool_version.installation( 145 | user.context.machine.id).directory.bookmarks["exe"] 146 | 147 | # begin command list to be sent to `subprocess` 148 | cmd_list = [tool_exe] 149 | 150 | # copy any gizmos, desktops, preferences, etc needed before launching 151 | self.pre_startup() 152 | assignment = assignment or user.assignments[-1] 153 | 154 | # establish the tool-specific project file to be opened 155 | project_file = tool_config.directory.bookmark(tool_config.tool_version.full_name).format( 156 | shot_version=assignment.shot_version) 157 | cmd_list.append(project_file) 158 | 159 | # TODO: replace with `logging` 160 | sys.stdout.write("\n\nStarting {tool.name} {tool_version.string}:\n{assignment} ...\n\n".format( 161 | assignment=pformat({ 162 | "Project": assignment.shot_version.project.name, 163 | "Shot": assignment.shot.name, 164 | "Version": assignment.shot_version.num 165 | }, 166 | width=1), 167 | tool=tool_config.tool, 168 | tool_version=tool_version 169 | )) 170 | return subprocess.Popen(cmd_list, shell=False, env=env_) 171 | 172 | def pre_startup(self): 173 | """Perform any custom python scripts then any copy operations.""" 174 | return True 175 | 176 | def post_startup(self): 177 | """Perform any custom python scripts then any copy operations.""" 178 | return True 179 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | """This script is an example of how to create a basic magla ecosystem from the ground up. 2 | 3 | All creation and deletion methods are in `r`, so this is primarily a demonstration of 4 | using the creation methods in the optimal order. 5 | 6 | Each creation method will return the created `MaglaEntity` or in the case that a record already 7 | exists, creation will abort and return the found record instead. To instead return an 8 | `EntityAlreadyExistsError`, you must call the `r.create` method directly and pass the 9 | 'return_existing=False` entity_test_fixtureeter 10 | 11 | example: 12 | ``` 13 | r.create(magla.User, {"nickname": "foo"}, return_existing=False) 14 | ``` 15 | 16 | This functionality is demonstrated below where the name of the shot being created is set to 17 | increment - meaning that running this script repeatedly will result in new shot and directory 18 | tree structures under the same project. 19 | """ 20 | import getpass 21 | 22 | import magla 23 | 24 | r = magla.Root() 25 | 26 | # Create Facility 27 | facility = r.create_facility("test_facility", 28 | settings={ 29 | "tool_install_directory_label": "{tool_version.tool.name}_{tool_version.string}"} 30 | ) 31 | 32 | # Create Machine 33 | current_machine = r.create_machine(facility.id) 34 | 35 | # Create Project 36 | test_project = r.create_project("test_project", "/mnt/projects/test_project", 37 | settings={ 38 | "project_directory": "/mnt/projects/{project.name}", 39 | "project_directory_tree": [ 40 | {"shots": []}, 41 | {"audio": []}, 42 | {"preproduction": [ 43 | {"mood": []}, 44 | {"reference": []}, 45 | {"edit": []}] 46 | }], 47 | # (prefix)(frame-padding)(suffix) 48 | "frame_sequence_re": r"(\w+\W)(\#+)(.+)", 49 | "shot_directory": "{shot.project.directory.path}/shots/{shot.name}", 50 | "shot_directory_tree": [ 51 | {"_current": [ 52 | {"h265": []}, 53 | {"png": []}, 54 | {"webm": []}] 55 | }], 56 | "shot_version_directory": "{shot_version.shot.directory.path}/{shot_version.num}", 57 | "shot_version_directory_tree": [ 58 | {"_in": [ 59 | {"plate": []}, 60 | {"subsets": []} 61 | ]}, 62 | {"_out": [ 63 | {"representations": [ 64 | {"exr": []}, 65 | {"png": []}, 66 | {"mov": []}] 67 | }] 68 | }], 69 | "shot_version_bookmarks": { 70 | "png_representation": "representations/png_sequence/_out/png/{shot_version.full_name}.####.png" 71 | } 72 | }) 73 | 74 | # Create 2D settings template 75 | settings_2d = r.create(magla.Settings2D, { 76 | "label": "Full 4K @30FPS", 77 | "width": 4096, 78 | "height": 2048, 79 | "rate": 30, 80 | "project_id": 1 81 | }) 82 | 83 | # Create Tool, ToolVersion, ToolVersionInstallation, FileType 84 | natron_2 = r.create_tool( 85 | tool_name="natron", 86 | install_dir="/opt/Natron-2.3.15", 87 | exe_path="/opt/Natron-2.3.15/bin/natron", 88 | version_string="2.3.15", 89 | file_extension=".ntp") 90 | 91 | # Create ToolConfig in order to have tool-specific subdirs and launch settings 92 | natron_tool_config = r.create_tool_config( 93 | tool_version_id=natron_2.id, 94 | project_id=test_project.id, 95 | tool_subdir="{tool_version.full_name}", 96 | bookmarks={ 97 | "{tool_version.full_name}": "{shot_version.full_name}.ntp" 98 | }, 99 | directory_tree=[ 100 | {"_in": [ 101 | {"plate": []}, 102 | {"subsets": []} 103 | ]}, 104 | {"_out": [ 105 | {"exr": []}, 106 | {"png": []}, 107 | {"subsets": []}] 108 | }] 109 | ) 110 | 111 | houdini_18 = r.create_tool( 112 | tool_name="houdini", 113 | install_dir="/opt/hfs18.0.532", 114 | exe_path="/opt/hfs18.0.532/bin/houdini", 115 | version_string="18.0.532", 116 | file_extension=".hipnc") 117 | 118 | houdini_tool_config = r.create_tool_config( 119 | tool_version_id=houdini_18.id, 120 | project_id=test_project.id, 121 | tool_subdir="{tool_version.full_name}", 122 | bookmarks={ 123 | "{tool_version.full_name}": "{shot_version.full_name}.hipnc" 124 | }, 125 | directory_tree=[ 126 | {"_in": [ 127 | {"subsets": []} 128 | ]}, 129 | {"_out": [ 130 | {"subsets": []} 131 | ]} 132 | ] 133 | ) 134 | 135 | # create modo tool 136 | modo_14_1v1 = r.create_tool( 137 | tool_name="modo", 138 | install_dir="/opt/Modo14.1v1", 139 | exe_path="/opt/Modo14.1v1/modo", 140 | version_string="14.1v1", 141 | file_extension=".lxo" 142 | ) 143 | 144 | # create new modo tool version 145 | modo_14_1v2 = r.create_tool_version( 146 | tool_id=modo_14_1v1.tool.id, 147 | version_string="14.1v2", 148 | install_dir="/opt/Modo14.1v2", 149 | exe_path="/opt/Modo14.1v2/modo" 150 | ) 151 | 152 | modo_tool_config = r.create_tool_config( 153 | tool_version_id=modo_14_1v1.id, 154 | project_id=test_project.id, 155 | tool_subdir="{tool_version.full_name}", 156 | bookmarks={ 157 | "{tool_version.full_name}": "{shot_version.full_name}.lxo" 158 | }, 159 | directory_tree=[ 160 | {"_in": [ 161 | {"subsets": []} 162 | ]}, 163 | {"_out": [ 164 | {"subsets": []} 165 | ]} 166 | ] 167 | ) 168 | 169 | # Create Shot 170 | shot = r.create_shot( 171 | project_id=test_project.id, name="test_shot_{}".format(len(test_project.shots))) 172 | 173 | # Create User 174 | # `magla` user nickname must match the OS's user name 175 | user = r.create_user(getpass.getuser()) 176 | 177 | # Create Assignment 178 | assignment = r.create_assignment( 179 | shot_id=shot.id, 180 | user_id=user.id 181 | ) 182 | 183 | # set user's assignment context 184 | # c = user.context 185 | # c.data.assignment_id = assignment.id 186 | # c.data.push() 187 | # use `all` method to retrieve list of all entity records by entity type. 188 | # r.all(magla.User) 189 | # r.all(magla.ShotVersion) 190 | # r.all(magla.Directory) 191 | 192 | # Building and exporting timelines 193 | # t = test_project.timeline 194 | # current process is sending list of `MaglaShot` objects to `build` method 195 | # t.build(test_project.shots) 196 | # `MaglaShot` objects include a 'track_index' and 'start_frame_in_parent' property which are 197 | # external to `opentimlineio` but used by `magla` for automatic building. This implementation 198 | # may change. 199 | # t.otio.to_json_file("test_project.json") 200 | 201 | # tools can be launched from `MaglaTool` or `MaglaToolVersion` instances 202 | # houdini_18.tool.start() 203 | # by calling `start` from the tool, version will be derrived from user's context 204 | # modo_14_1v2.tool.start() 205 | # alternitively, calling `start` directly from the tool version, that version will override configs 206 | # modo_14_1v2.start() 207 | -------------------------------------------------------------------------------- /magla/core/timeline.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Timelines are trackless and clipless representations of an `opentimelineio.schema.Timeline` 3 | which self-build themselves dynamically based on whatever list of `MaglaShot` you feed in.""" 4 | import getpass 5 | import logging 6 | import os 7 | 8 | import opentimelineio as otio 9 | from opentimelineio.opentime import RationalTime as RTime 10 | 11 | from ..db.timeline import Timeline 12 | from .entity import MaglaEntity 13 | from .errors import MaglaError 14 | 15 | 16 | class MaglaTimelineError(MaglaError): 17 | """An error accured preventing MaglaTimeline to continue.""" 18 | 19 | 20 | class MaglaTimeline(MaglaEntity): 21 | """Provide an interface for building and exporting `opentimelineio.schema.Timeline`. 22 | 23 | For usage see `magla.core.project.MaglaProject` 24 | """ 25 | __schema__ = Timeline 26 | 27 | def __init__(self, data=None, **kwargs): 28 | """Initialize with given data. 29 | 30 | Parameters 31 | ---------- 32 | data : dict 33 | Data to query for matching backend record 34 | """ 35 | super(MaglaTimeline, self).__init__(data or dict(kwargs)) 36 | 37 | def __repr__(self): 38 | return "".format(this=self) 39 | 40 | def __str__(self): 41 | return self.__repr__() 42 | 43 | @property 44 | def id(self): 45 | """Retrieve id from data. 46 | 47 | Returns 48 | ------- 49 | int 50 | Postgres column id 51 | """ 52 | return self.data.id 53 | 54 | @property 55 | def label(self): 56 | """Retrieve label from data. 57 | 58 | Returns 59 | ------- 60 | str 61 | A descriptive label for the timeline 62 | """ 63 | return self.data.label 64 | 65 | @property 66 | def otio(self): 67 | """Retrieve otio from data. 68 | 69 | Returns 70 | ------- 71 | opentimelineio.schema.Timeline 72 | The live timeline object 73 | """ 74 | return self.data.otio 75 | 76 | # SQAlchemy relationship back-references 77 | @property 78 | def user(self): 79 | """Shortcut method to retrieve related `MaglaUser` back-reference. 80 | 81 | Returns 82 | ------- 83 | magla.core.user.MaglaUser 84 | The `MaglaUser` owner of this timeline if any 85 | """ 86 | r = self.data.record.user 87 | return MaglaEntity.from_record(r) 88 | 89 | # MaglaTimeline-specific methods ______________________________________________________________ 90 | def build(self, shots): 91 | """Build necessary tracks and populate with given shots. 92 | 93 | Parameters 94 | ---------- 95 | shots : list 96 | List of shots to populate timeline with 97 | """ 98 | shots = sorted(shots, key=lambda shot: shot.id) 99 | for shot in shots: 100 | self.insert_shot(shot) 101 | return self 102 | 103 | def insert_shot(self, shot): 104 | """Insert given shot into timeline. 105 | 106 | Parameters 107 | ---------- 108 | shot : magla.core.shot.MaglaShot 109 | The `MaglaShot` to insert 110 | """ 111 | # build tracks for given shot 112 | track_index = shot.track_index or 1 113 | num_tracks = len(self.otio.tracks) 114 | if num_tracks < (track_index): 115 | for i in range(num_tracks, track_index): 116 | self.otio.tracks.append(otio.schema.Track(name="magla_track_{index}".format( 117 | index=i 118 | ))) 119 | track = self.otio.tracks[track_index-1] 120 | shot.data.track_index = track_index 121 | # if there's no placement information place it at the end of current last clip. 122 | clip = track.child_at_time( 123 | RTime(shot.start_frame_in_parent or 0, shot.project.settings_2d.rate)) 124 | if shot.start_frame_in_parent == None or clip: 125 | shot.data.start_frame_in_parent = int( 126 | track.available_range().duration.value) 127 | shot.data.push() 128 | self.__insert_shot(shot) 129 | 130 | def __append_shot(self, shot, gap=None): 131 | """Append an `opentimelineio.schema.Gap` if needed, then append given shot to it's track. 132 | 133 | Parameters 134 | ---------- 135 | shot : magla.core.shot.MaglaShot 136 | The `MaglaShot` to append 137 | gap : opentimelineio.schema.Gap 138 | The gap to insert if provided 139 | """ 140 | if gap: 141 | self.data.otio.tracks[shot.track_index-1].extend([gap, shot.otio]) 142 | else: 143 | self.data.otio.tracks[shot.track_index-1].append(shot.otio) 144 | 145 | def __insert_shot(self, shot): 146 | """Insert an `opentimelineio.schema.Clip` by splitting the occupying `Gap`. 147 | 148 | Parameters 149 | ---------- 150 | shot : magla.core.shot.MaglaShot 151 | The `MaglaShot` to append 152 | 153 | Raises 154 | ------ 155 | MaglaTimelineError 156 | Thrown if anything other than an `opentimelineio.schema.Gap` is encountered 157 | """ 158 | track_index = shot.track_index or 1 159 | track = self.otio.tracks[track_index-1] 160 | x = track.available_range().duration.value 161 | start_frame = float(shot.start_frame_in_parent) 162 | if start_frame == x: 163 | # no gap needed 164 | self.__append_shot(shot) 165 | elif start_frame > x: 166 | # gap needed 167 | last_clip = track[-1] 168 | gap_start = last_clip.range_in_parent().end_time_exclusive().value 169 | gap_duration = start_frame - gap_start 170 | gap = otio.schema.Gap(duration=RTime(float(gap_duration))) 171 | self.__append_shot(shot, gap) 172 | else: 173 | # insert clip at it's `start_frame` while splitting the `Gap` 174 | gap = track.child_at_time(RTime( 175 | start_frame, shot.project.settings_2d.rate)) 176 | if not isinstance(gap, otio.schema.Gap): 177 | raise MaglaTimelineError( 178 | "Expected {0}, but got: {1}".format(otio.schema.Gap, gap)) 179 | 180 | # prepare to split gap for insertion 181 | gap_start = gap.range_in_parent().start_time 182 | gap_duration = gap.range_in_parent().end_time_exclusive() - gap_start 183 | 184 | # define new gap duration 185 | new_gap_duration = RTime(start_frame - gap_start, gap_duration.rate) 186 | 187 | # apply new gap duration 188 | gap.source_range = otio.opentime.TimeRange( 189 | start_time=gap_start, 190 | duration=new_gap_duration) 191 | 192 | # insert our shot clip 193 | self.otio.tracks[track_index-1].insert(track.index(gap) + 1, shot.otio) 194 | 195 | # append spacer gap if needed 196 | gap_duration = gap_duration - (new_gap_duration + shot.otio.source_range.duration) 197 | self.otio.tracks[track_index-1].insert(track.index(gap) + 1, otio.schema.Gap( 198 | duration=RTime(gap_duration, gap.duration.rate))) 199 | -------------------------------------------------------------------------------- /magla/utils.py: -------------------------------------------------------------------------------- 1 | """Utility functions.""" 2 | import configparser 3 | import json 4 | import os 5 | import random 6 | import subprocess 7 | import sys 8 | import uuid 9 | 10 | import opentimelineio as otio 11 | 12 | 13 | class MaglaUtilsError(Exception): 14 | """Root error class for `magla.utils` module.""" 15 | 16 | 17 | class MachineConfigNotFoundError(MaglaUtilsError): 18 | """No `machine.ini` file found on current machine, or at the given target path.""" 19 | 20 | 21 | def get_machine_uuid(path=None): 22 | """Retrieve the unique machine uuid from this machine's `site_config_dir` if one exists. 23 | 24 | Parameters 25 | ---------- 26 | file : str, optional 27 | The path to the `machine.ini` file for this machine, by default None 28 | 29 | Returns 30 | ------- 31 | str 32 | Unique string identifying this machine within the `magla` ecosystem. 33 | """ 34 | machine_config = configparser.ConfigParser() 35 | machine_ini = path or os.path.join( 36 | os.environ["MAGLA_MACHINE_CONFIG_DIR"], "machine.ini") 37 | if not os.path.isfile(machine_ini): 38 | raise MachineConfigNotFoundError(machine_ini) 39 | machine_config.read(machine_ini) 40 | return machine_config["DEFAULT"].get("uuid") 41 | 42 | 43 | def generate_machine_uuid(): 44 | """Generate a UUID string which is unique to current machine. 45 | 46 | Returns 47 | ------- 48 | str 49 | Unique string 50 | """ 51 | return uuid.UUID(int=uuid.getnode()) 52 | 53 | 54 | def write_machine_uuid(string=None, makefile=True): 55 | """Create and write UUID string to the current machine's `machine.ini` file. 56 | 57 | Parameters 58 | ---------- 59 | string : str, optional 60 | the unique id to use for current machine, by default None 61 | makefile : bool, optional 62 | flag for creating `machine.ini` if it doesn't exist, by default True 63 | """ 64 | machine_config = configparser.ConfigParser() 65 | machine_config["DEFAULT"]["uuid"] = string or str(generate_machine_uuid()) 66 | if not os.path.isdir(os.environ["MAGLA_MACHINE_CONFIG_DIR"]): 67 | os.makedirs(os.environ["MAGLA_MACHINE_CONFIG_DIR"]) 68 | with open(os.path.join(os.environ["MAGLA_MACHINE_CONFIG_DIR"], "machine.ini"), "w+") as fo: 69 | machine_config.write(fo) 70 | return machine_config["DEFAULT"]["uuid"] 71 | 72 | 73 | def otio_to_dict(target): 74 | """TODO: Convert given `opentimelineio.schema.SerializeableObject` object to dict. 75 | 76 | Parameters 77 | ---------- 78 | otio : opentimelineio.schema.SerializeableObject 79 | The `opentimelineio` object to convert 80 | 81 | Returns 82 | ------- 83 | dict 84 | Dict representing the given `opentimelineio.schema.SerializeableObject` 85 | """ 86 | if isinstance(target, otio.core.SerializableObjectWithMetadata): 87 | stringify = target.to_json_string(indent=-1) 88 | return json.loads(stringify) 89 | if isinstance(target, dict) and "otio" in target: 90 | if isinstance(target["otio"], otio.core.SerializableObjectWithMetadata): 91 | stringify = target["otio"].to_json_string(indent=-1) 92 | target["otio"] = json.loads(stringify) 93 | return target 94 | return target 95 | 96 | 97 | def dict_to_otio(target): 98 | """Convert a previously converted dict back to an `opentimelineio.schema.SerializeableObject`. 99 | 100 | Parameters 101 | ---------- 102 | target : dict 103 | The dict to convert 104 | 105 | Returns 106 | ------- 107 | opentimelineio.schema.SerializeableObject 108 | The object created from given dict 109 | 110 | Raises 111 | ------ 112 | Exception 113 | Raised if bad argument given. 114 | """ 115 | if isinstance(target, dict) and "otio" in target: 116 | if isinstance(target["otio"], otio.core.SerializableObjectWithMetadata): 117 | return target 118 | stringify = json.dumps(target["otio"]) 119 | target["otio"] = otio.adapters.read_from_string(stringify) 120 | return target 121 | if not is_otio_dict(target): 122 | return target 123 | return otio.adapters.read_from_string(json.dumps(target)) 124 | 125 | 126 | def is_otio_dict(dict_): 127 | """Determine if given dict can be converted to an `opentimelineio.schema.SerializeableObject` 128 | 129 | Parameters 130 | ---------- 131 | dict_ : dict 132 | Dict to check 133 | 134 | Returns 135 | ------- 136 | bool 137 | True if can be converted, False if not 138 | """ 139 | return isinstance(dict_, dict) and "OTIO_SCHEMA" in dict_ 140 | 141 | 142 | def record_to_dict(record, otio_as_dict=True): 143 | """Convert given `sqlalchemy.ext.declarative.api.Base` mapped entity to dict. 144 | 145 | Parameters 146 | ---------- 147 | record : sqlalchemy.ext.declarative.api.Base 148 | The `SQAlchemy` record to convert 149 | otio_as_dict : bool, optional 150 | Flag whether or not to also convert back to an object, by default True 151 | 152 | Returns 153 | ------- 154 | dict 155 | Dict representation of given record 156 | """ 157 | dict_ = {} 158 | "this method needs to retrieve dict from a mapped entity object." 159 | for c in list(record.__table__.c): 160 | val = getattr(record, c.name) 161 | if otio_as_dict and is_otio_dict(val): 162 | val = otio_to_dict(val) 163 | else: 164 | val = dict_to_otio(val) 165 | dict_[c.name] = val 166 | return dict_ 167 | 168 | 169 | def apply_dict_to_record(record, data, otio_as_dict=True): 170 | """Convert given dict to `SQLAlchemy` record. 171 | 172 | Parameters 173 | ---------- 174 | record : sqlalchemy.ext.declarative.api.Base 175 | Class for record to create 176 | data : dict 177 | Dict containing data to use in conversion 178 | otio_as_dict : bool, optional 179 | Flag whether or not to convert previously converted dict back to object, by default True 180 | 181 | Returns 182 | ------- 183 | sqlalchemy.ext.declarative.api.Base 184 | Instantiated record containing populated with given data 185 | """ 186 | for key, val in data.items(): 187 | if otio_as_dict: 188 | if isinstance(val, otio.core.SerializableObject): 189 | val = otio_to_dict(val) 190 | else: 191 | if is_otio_dict(val): 192 | val = dict_to_otio(val) 193 | setattr(record, key, val) 194 | return record 195 | 196 | 197 | def open_directory_location(target_path): 198 | """Open given target path using current operating system. 199 | 200 | Parameters 201 | ---------- 202 | target_path : str 203 | Path to open 204 | """ 205 | proc = None 206 | if not isinstance(target_path, str): 207 | raise Exception("Must provide string!") 208 | if sys.platform == 'win32': 209 | proc = subprocess.Popen(['start', target_path], shell=True) 210 | elif sys.platform == 'darwin': 211 | proc = subprocess.Popen(['open', target_path]) 212 | else: 213 | proc = subprocess.Popen(['xdg-open', target_path]) 214 | return proc 215 | 216 | 217 | def random_string(choices_str, length): 218 | """Generate a random string from the given `choices_str`. 219 | 220 | Parameters 221 | ---------- 222 | choices_str : str 223 | A string containing all the possible choice characters 224 | length : int 225 | The desired length of the resulting string 226 | 227 | Returns 228 | ------- 229 | str 230 | A random string of characters 231 | """ 232 | return ''.join(random.choice(str(choices_str)) for _ in range(length)) 233 | 234 | -------------------------------------------------------------------------------- /magla/core/shot.py: -------------------------------------------------------------------------------- 1 | """Shots are a collection of versions of `subsets` used in the creation of a final `representation`. 2 | 3 | It is important to note that a `MaglaShot` object by itself should rarely be accessed except when 4 | assigning, or if `subsets` need to be managed. The actual content making up the shot is contained 5 | in individual `MaglaShotVersion` directories. 6 | 7 | TODO: should be able to instantiate shot without an otio object initially 8 | """ 9 | import opentimelineio as otio 10 | 11 | from ..db.shot import Shot 12 | from .entity import MaglaEntity 13 | from .errors import MaglaError 14 | from .shot_version import MaglaShotVersion 15 | 16 | 17 | class MaglaShotError(MaglaError): 18 | """An error accured preventing MaglaShot to continue.""" 19 | 20 | 21 | class MaglaShot(MaglaEntity): 22 | """Provide an interface for shot properties and assignment.""" 23 | __schema__ = Shot 24 | 25 | def __init__(self, data=None, **kwargs): 26 | """Initialize with given data. 27 | 28 | Parameters 29 | ---------- 30 | data : dict 31 | Data to query for matching backend record 32 | """ 33 | if isinstance(data, str): 34 | data = {"name": data} 35 | super(MaglaShot, self).__init__(data or dict(kwargs)) 36 | if self.versions and self.otio: 37 | self.otio.media_reference = self.versions[-1].otio 38 | elif self.otio: 39 | self.otio.media_reference = otio.schema.MissingReference() 40 | 41 | @property 42 | def id(self): 43 | """Retrieve id from data. 44 | 45 | Returns 46 | ------- 47 | int 48 | Postgres column id 49 | """ 50 | return self.data.id 51 | 52 | @property 53 | def name(self): 54 | """Retrieve name from data. 55 | 56 | Returns 57 | ------- 58 | str 59 | Name of the shot 60 | """ 61 | return self.data.name 62 | 63 | @property 64 | def otio(self): 65 | """Retrieve otio from data. 66 | 67 | Returns 68 | ------- 69 | opentimelineio.schema.Clip 70 | The `Clip` object for this shot 71 | """ 72 | return self.data.otio 73 | 74 | @property 75 | def track_index(self): 76 | """Retrieve track_index from data. 77 | 78 | Returns 79 | ------- 80 | int 81 | The index of the `opentimelineio.schema.Track` object this shot belongs to 82 | """ 83 | return self.data.track_index 84 | 85 | @property 86 | def start_frame_in_parent(self): 87 | """Retrieve start_frame_in_parent from data. 88 | 89 | Returns 90 | ------- 91 | int 92 | Frame number in the timeline that this shot populates inserts itself at 93 | """ 94 | return self.data.start_frame_in_parent 95 | 96 | # SQAlchemy relationship back-references 97 | @property 98 | def episode(self): 99 | """Shortcut method to retrieve related `MaglaEpisode` back-reference. 100 | 101 | Returns 102 | ------- 103 | magla.core.episode.MaglaEpisode 104 | The `MaglaEpisode` for this shot 105 | """ 106 | return MaglaEntity.from_record(self.data.episode.data.record) 107 | 108 | @property 109 | def sequence(self): 110 | """Shortcut method to retrieve related `MaglaSequence` back-reference. 111 | 112 | Returns 113 | ------- 114 | magla.core.sequence.MaglaSequence 115 | The `MaglaSequence` for this shot 116 | """ 117 | return MaglaEntity.from_record(self.data.sequence.data.record) 118 | 119 | @property 120 | def directory(self): 121 | """Shortcut method to retrieve related `MaglaDirectory` back-reference. 122 | 123 | Returns 124 | ------- 125 | magla.core.directory.MaglaDirectory 126 | The `MaglaDirectory` for this shot 127 | """ 128 | return MaglaEntity.from_record(self.data.record.directory) 129 | 130 | @property 131 | def project(self): 132 | """Shortcut method to retrieve related `MaglaProject` back-reference. 133 | 134 | Returns 135 | ------- 136 | magla.core.project.MaglaProject 137 | The `MaglaProject` for this shot 138 | """ 139 | r = self.data.record.project 140 | if not r: 141 | return None 142 | return MaglaEntity.from_record(r) 143 | 144 | @property 145 | def versions(self): # TODO: this is heavy.. need to optomize entity instantiation 146 | """Shortcut method to retrieve related `MaglaShotVersion` back-reference list. 147 | 148 | Returns 149 | ------- 150 | list of magla.core.shot_version.MaglaShotVersion 151 | A list of `MaglaShotVersion` for this project 152 | """ 153 | r = self.data.record.versions 154 | if r == None: 155 | return [] 156 | return [self.from_record(a) for a in r] 157 | 158 | # MaglaShot-specific methods ________________________________________________________________ 159 | def version(self, num): 160 | """Retrieve a specific `MaglaShotVersion by its version number int. 161 | 162 | Parameters 163 | ---------- 164 | num : int 165 | The version number integer of the target shot version 166 | 167 | Returns 168 | ------- 169 | magla.core.shot_version.MaglaShotVersion 170 | The `MaglaShotVersion` object retrieved or None 171 | """ 172 | if not isinstance(num, int): 173 | num = int(num) # TODO exception handling needed here 174 | return MaglaShotVersion(shot_id=self.data.id, num=num) 175 | 176 | @property 177 | def full_name(self): 178 | """Convenience method for prefixing the shot's name with the project's name 179 | 180 | Returns 181 | ------- 182 | str 183 | Shot name prefixed with project name 184 | 185 | Example: 186 | ``` 187 | project_name_shot_name 188 | ``` 189 | """ 190 | return "{project_name}_{shot_name}".format( 191 | project_name=self.project.name, 192 | shot_name=self.name) 193 | 194 | @property 195 | def latest_num(self): 196 | """Retrieve the version number integer of the latest version of this shot. 197 | 198 | Returns 199 | ------- 200 | int 201 | The version number of the latest version 202 | """ 203 | return self._data.record.versions[-1].num if self._data.record.versions else 0 204 | 205 | def latest(self): 206 | return self.version(self.latest_num) 207 | 208 | def version_up(self, magla_root_callback): 209 | """Create a new `MaglaShotVersion` record by incrementing from the latest version.\ 210 | 211 | Since creation and deletion must go through magla.Root, we use a callback to perform the 212 | actual creation logic. 213 | 214 | Parameters 215 | ---------- 216 | magla_root_callback : magla.Root.version_up 217 | The version up method from magla.Root with creation privilege 218 | 219 | Returns 220 | ------- 221 | magla.core.shot_version.MaglaShotVersion 222 | The newly created `MaglaShotVersion` object 223 | """ 224 | new_version = magla_root_callback(self.id, self.latest_num + 1) 225 | return new_version 226 | 227 | def set_media_reference(self, shot_version): 228 | """Apply given `MaglaShotVersion`'s media reference to the `Clip`. 229 | 230 | Parameters 231 | ---------- 232 | shot_version : magla.shot_version.MaglaShotVersion 233 | The `MaglaShotVersion` to use as the current media reference 234 | """ 235 | self._otio.media_reference = shot_version._otio 236 | -------------------------------------------------------------------------------- /magla/db/orm.py: -------------------------------------------------------------------------------- 1 | """This is the primary database interface for Magla and SQLAlchemy/Postgres. 2 | 3 | This module is intended to serve as a generic python `CRUD` interface and should remain decoupled 4 | from `magla`. 5 | 6 | To replace with your own backend just keep the below method signatures intact. 7 | """ 8 | import os 9 | 10 | from sqlalchemy import create_engine 11 | from sqlalchemy.ext.declarative import declarative_base 12 | from sqlalchemy.orm.session import sessionmaker 13 | from sqlalchemy_utils import database_exists, create_database, drop_database 14 | 15 | 16 | class MaglaORM(object): 17 | """Manage the connection to backend and facilitate `CRUD` operations. 18 | 19 | This Class is meant to serve as an adapter to any backend in case a different one is desired. 20 | DB connection settings and credentials should also be managed here. 21 | 22 | All conversions form backend data to `MaglaEntity` objects or lists should happen here using 23 | the `from_record` or `from_dict` classmethods. In this way rhe core `magla` module can remain 24 | decoupled. 25 | """ 26 | # `postgres` connection string variables 27 | CONFIG = { 28 | "dialect": "sqlite", 29 | "username": os.getenv("MAGLA_DB_USERNAME"), 30 | "password": os.getenv("MAGLA_DB_PASSWORD"), 31 | "hostname": os.getenv("MAGLA_DB_HOSTNAME"), 32 | "port": os.getenv("MAGLA_DB_PORT"), 33 | "db_name": os.getenv("MAGLA_DB_NAME"), 34 | "data_dir": os.getenv("MAGLA_DB_DATA_DIR") 35 | } 36 | _Base = declarative_base() 37 | _Session = None 38 | _Engine = None 39 | 40 | def __init__(self): 41 | """Instantiate and iniliatize DB tables.""" 42 | self._session = None 43 | 44 | def init(self): 45 | """Perform `SQLAlchemy` initializations, create filesystem directory for `sqlite` data.""" 46 | self._construct_engine() 47 | if not os.path.isdir(self.CONFIG["data_dir"]): 48 | os.makedirs(self.CONFIG["data_dir"]) 49 | if not database_exists(self._Engine.url): 50 | create_database(self._Engine.url) 51 | self._construct_session() 52 | self._create_all_tables() 53 | self._session = self._Session() 54 | 55 | @property 56 | def session(self): 57 | """Retrieve Session class or Session Instance (after `_construct_session` has been called). 58 | 59 | Returns 60 | ------- 61 | sqlalchemy.orm.Session 62 | Session class/object 63 | """ 64 | return self._session 65 | 66 | @classmethod 67 | def _create_all_tables(cls): 68 | """Create all tables currently defined in metadata.""" 69 | cls._Base.metadata.create_all(cls._Engine) 70 | 71 | @classmethod 72 | def _drop_all_tables(cls): 73 | """Drop all tables currently defined in metadata.""" 74 | cls._Base.metadata.drop_all(bind=cls._Engine) 75 | 76 | @classmethod 77 | def _construct_session(cls, *args, **kwargs): 78 | """Construct session-factory.""" 79 | # TODO: include test coverage for constructing sessions with args/kwargs 80 | cls._Session = cls.sessionmaker(*args, **kwargs) 81 | 82 | @classmethod 83 | def _construct_engine(cls): 84 | """Construct a `SQLAlchemy` engine of the type currently set in `CONFIG['dialect']`.""" 85 | callable_ = getattr( 86 | cls, 87 | "_construct_{dialect}_engine".format(dialect=cls.CONFIG["dialect"]), 88 | cls._construct_sqlite_engine 89 | ) 90 | callable_() 91 | 92 | @classmethod 93 | def _construct_sqlite_engine(cls): 94 | """Construct the engine to be used by `SQLAlchemy`.""" 95 | cls._Engine = create_engine( 96 | "sqlite:///{data_dir}/{db_name}".format(**cls.CONFIG) 97 | ) 98 | 99 | @classmethod 100 | def _construct_postgres_engine(cls): 101 | """Construct the engine to be used by `SQLAlchemy`.""" 102 | cls._Engine = create_engine( 103 | "postgresql://{username}:{password}@{hostname}:{port}/{db_name}".format(**cls.CONFIG) 104 | ) 105 | 106 | @classmethod 107 | def sessionmaker(cls, **kwargs): 108 | """Create new session factory. 109 | 110 | Returns 111 | ------- 112 | sqlalchemy.orm.sessionmaker 113 | Session factory 114 | """ 115 | return sessionmaker(bind=cls._Engine, **kwargs) 116 | 117 | def _query(self, entity, **filter_kwargs): 118 | """Query the `SQLAlchemy` session for given entity and data. 119 | 120 | Parameters 121 | ---------- 122 | entity : magla.core.entity.MaglaEntity 123 | The sub-entity to be queried 124 | 125 | Returns 126 | ------- 127 | sqlalchemy.ext.declarative.api.Base 128 | The returned record from the session query (containing data directly from backend) 129 | """ 130 | return self.session.query(entity).filter_by(**filter_kwargs) 131 | 132 | def all(self, entity=None): 133 | """Retrieve all columns from entity's table. 134 | 135 | Parameters 136 | ---------- 137 | entity : magla.core.entity.MaglaEntity, optional 138 | The specific sub-entity type to query, by default None 139 | 140 | Returns 141 | ------- 142 | list 143 | List of `MaglaEntity` objects instantiated from each record. 144 | """ 145 | entity = entity or self.entity 146 | return [entity.from_record(record) for record in self.query(entity).all()] 147 | 148 | def one(self, entity=None, **filter_kwargs): 149 | """Retrieve the first found record. 150 | 151 | Parameters 152 | ---------- 153 | entity : magla.core.entity.MaglaEntity, optional 154 | The specific sub-entity type to query, by default None 155 | 156 | Returns 157 | ------- 158 | magla.core.entity.MaglaEntity 159 | The found `MaglaEntity` or None 160 | """ 161 | entity = entity or self.entity 162 | record = self.query(entity, **filter_kwargs) 163 | return entity.from_record(record.first()) 164 | 165 | def create(self, entity, data): 166 | """Create the given entity type using given data. 167 | 168 | Parameters 169 | ---------- 170 | entity : magla.core.entity.MaglaEntity 171 | The entity type to create 172 | data : dict 173 | A dictionary containing data to create new record with 174 | 175 | Returns 176 | ------- 177 | magla.core.entity.MaglaEntity 178 | A `MaglaEntity` from the newly created record 179 | """ 180 | new_entity_record = entity.__schema__(**data) 181 | self.session.add(new_entity_record) 182 | self.session.commit() 183 | return entity.from_record(new_entity_record) 184 | 185 | def delete(self, entity): 186 | """Delete the given entity. 187 | 188 | Parameters 189 | ---------- 190 | entity : sqlalchemy.ext.declarative.api.Base 191 | The `SQLAlchemy` mapped entity object to drop 192 | """ 193 | self.session.delete(entity) 194 | self.session.commit() 195 | 196 | def query(self, entity, data=None, **filter_kwargs): 197 | """Query the `SQLAlchemy` session for given entity type and data/kwargs. 198 | 199 | Parameters 200 | ---------- 201 | entity : magla.core.entity.MaglaEntity 202 | The sub-class of `MaglaEntity` to query 203 | data : dict, optional 204 | A dictionary containing the data to query for, by default None 205 | 206 | Returns 207 | ------- 208 | sqlalchemy.orm.query.Query 209 | The `SQAlchemy` query object containing results 210 | """ 211 | data = data or {} 212 | data.update(dict(filter_kwargs)) 213 | entity = entity or self.entity 214 | return self._query(entity.__schema__, **data) 215 | -------------------------------------------------------------------------------- /magla/core/directory.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """Directories give `magla` an interface to interact with directory-structures and their contents. 3 | 4 | There should never be any hard-coded paths anywhere except by users via settings. If something 5 | relies on accessing local directories then a `MaglaDirectory` record must first be created and 6 | associated. 7 | """ 8 | import os 9 | import shutil 10 | import sys 11 | 12 | from ..db.directory import Directory 13 | from ..utils import open_directory_location 14 | from .entity import MaglaEntity 15 | from .errors import MaglaError 16 | 17 | 18 | class MaglaDirectoryError(MaglaError): 19 | """An error accured preventing MaglaDirectory to continue.""" 20 | 21 | 22 | class MaglaDirectory(MaglaEntity): 23 | """Provide an interface for interacting with local filesystem. 24 | 25 | `MaglaDirectory` objects are intended to represent a root location and encompass all children 26 | contained within rather than behaving in a hierarchal fashion. 27 | 28 | Structure of a `MaglaDirectory` object: 29 | 30 | label 31 | ----- 32 | A descriptive comment about this directory 33 | 34 | path 35 | ---- 36 | The path to the root of the directory 37 | 38 | tree 39 | ---- 40 | A description of a directory tree using nested dicts and lists 41 | 42 | example: 43 | ``` 44 | [ 45 | {"shots": []}, 46 | {"audio": []}, 47 | {"preproduction": [ 48 | {"mood": []}, 49 | {"reference": []}, 50 | {"edit": []}] 51 | } 52 | ] 53 | ``` 54 | 55 | results in following tree-structure: 56 | ``` 57 | shots 58 | audio 59 | preproduction 60 | |_mood 61 | |_reference 62 | |_edit 63 | ``` 64 | 65 | bookmarks 66 | --------- 67 | A dictionary to store locations within the directory (such as executeables, configs, etc). 68 | Python string formatting can be utilized as shown below. 69 | 70 | example: 71 | ``` 72 | shot_version_bookmarks = { 73 | "ocio": "_in/color/{shot_version.full_name}.ocio", 74 | "luts": "_in/color/luts", 75 | "representations": { 76 | "png_sequence": "_out/png/{shot_version.full_name}.####.png", 77 | "youtube_mov": "_out/png/{shot_version.full_name}.mov", 78 | "exr_sequence": "_out/png/{shot_version.full_name}.####.exr" 79 | } 80 | ``` 81 | """ 82 | __schema__ = Directory 83 | 84 | def __init__(self, data=None, **kwargs): 85 | """Initialize with given data. 86 | 87 | Parameters 88 | ---------- 89 | data : dict, optional 90 | Data to query for matching backend record 91 | """ 92 | super(MaglaDirectory, self).__init__(data or dict(kwargs)) 93 | 94 | def __repr__(self): 95 | return self.path 96 | 97 | def __str__(self): 98 | return self.__repr__() 99 | 100 | @property 101 | def id(self): 102 | """Retrieve id from data. 103 | 104 | Returns 105 | ------- 106 | int 107 | Postgres column id 108 | """ 109 | return self.data.id 110 | 111 | @property 112 | def label(self): 113 | """Retrieve label from data. 114 | 115 | Returns 116 | ------- 117 | str 118 | Postgres column label 119 | """ 120 | return self.data.label 121 | 122 | @property 123 | def path(self): 124 | """Retrieve path from data. 125 | 126 | Returns 127 | ------- 128 | str 129 | Postgres column path 130 | """ 131 | return self.data.path 132 | 133 | @property 134 | def tree(self): 135 | """Retrieve tree from data. 136 | 137 | Returns 138 | ------- 139 | dict 140 | Postgres column tree (JSON) 141 | """ 142 | return self.data.tree 143 | 144 | @property 145 | def bookmarks(self): 146 | """Retrieve bookmarks from data. 147 | 148 | Returns 149 | ------- 150 | dict 151 | Postgres column bookmarks (JSON) 152 | """ 153 | return self.data.bookmarks 154 | 155 | # SQAlchemy relationship back-references 156 | @property 157 | def machine(self): 158 | """Retrieve related `MaglaMachine` back-reference. 159 | 160 | Returns 161 | ------- 162 | magla.core.machine.MaglaMachine 163 | The `MaglaMachine` this directory exists on 164 | """ 165 | r = self.data.record.machine 166 | if not r: 167 | return None 168 | return MaglaEntity.from_record(r) 169 | 170 | @property 171 | def user(self): 172 | """Retrieve related `MaglaUser` back-reference. 173 | 174 | Returns 175 | ------- 176 | magla.core.user.MaglaUser 177 | The `MaglaUser` owner if any 178 | """ 179 | r = self.data.record.user 180 | if not r: 181 | return None 182 | return MaglaEntity.from_record(r) 183 | 184 | # MaglaDirectory-specific methods ______________________________________________________________ 185 | def bookmark(self, name): 186 | """Retrieve and convert given bookmark to absolute path. 187 | Parameters 188 | ---------- 189 | name : str 190 | The bookmark key name 191 | Returns 192 | ------- 193 | str 194 | The absolute path of the bookmark, or the if it does not exist an absolute path is 195 | created using the name as the relative path. 196 | """ 197 | return os.path.join(self.path, self.bookmarks.get(name, name)) 198 | 199 | def open(self): 200 | """Open the directory location in the os file browser.""" 201 | open_directory_location(self.path) 202 | 203 | def make_tree(self): 204 | """Create the directory tree on the machine's filesystem.""" 205 | if not os.path.isdir(self.path): 206 | os.makedirs(self.path) 207 | self._recursive_make_tree(self.path, self.tree or []) 208 | 209 | def bookmark(self, name): 210 | """Retrieve and convert given bookmark to absolute path. 211 | 212 | Parameters 213 | ---------- 214 | name : str 215 | The bookmark key name 216 | 217 | Returns 218 | ------- 219 | str 220 | The absolute path of the bookmark, or the if it does not exist an absolute path is 221 | created using the name as the relative path. 222 | """ 223 | return os.path.join(self.path, self.bookmarks.get(name, name)) 224 | 225 | def _recursive_make_tree(self, root, sub_tree): 226 | """Recursively create the directory tree. 227 | 228 | Parameters 229 | ---------- 230 | root : str 231 | The root directory of the current recursion loop. 232 | sub_tree : list 233 | The nested directies if any 234 | """ 235 | for dict_ in sub_tree: 236 | k, v = list(dict_.items())[0] 237 | abs_path = os.path.join(root, k) 238 | print("making: {}".format(abs_path)) 239 | try: 240 | os.makedirs(abs_path, exist_ok=False) 241 | except OSError as e: 242 | if e.errno == 17: # `FileExistsError` 243 | continue 244 | if v: 245 | self._recursive_make_tree(root=os.path.join(root, k), sub_tree=v) 246 | 247 | def delete_tree(self): 248 | try: 249 | shutil.rmtree(self.path) 250 | except OSError: 251 | raise 252 | sys.stdout.write("Deleted directory tree at: '{0}'".format(self.path)) 253 | -------------------------------------------------------------------------------- /tests/seed_data.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | Facility: 3 | - - id: 1 4 | name: test_facility 5 | settings: 6 | tool_install_directory_label: "{tool_version.tool.name}_{tool_version.string}" 7 | - true 8 | Machine: 9 | - - id: 1 10 | facility_id: 1 11 | uuid: 00000000-0000-0000-0000-7946f8ba868d 12 | name: test_machine 13 | ip_address: null 14 | - true 15 | User: 16 | - - id: 1 17 | first_name: foo 18 | last_name: bar 19 | nickname: foobar 20 | email: foo@bar.com 21 | - true 22 | - - id: 2 23 | first_name: fizz 24 | last_name: bazz 25 | nickname: foobar 26 | email: foo@bar.com 27 | - true 28 | Directory: 29 | - - id: 1 30 | machine_id: 1 31 | user_id: 1 32 | label: test_user's working directory on machine 1. 33 | path: "/home/jacob/working_dir" 34 | tree: 35 | - current_assignments: [] 36 | - ".backups": [] 37 | bookmarks: 38 | backups: ".backups" 39 | current_assignments: current_assignments 40 | - true 41 | - - id: 2 42 | machine_id: 1 43 | user_id: null 44 | label: test_project root directory. 45 | path: "/mnt/projects/test_project" 46 | bookmarks: {} 47 | tree: 48 | - shots: [] 49 | - audio: [] 50 | - preproduction: 51 | - mood: [] 52 | - reference: [] 53 | - edit: [] 54 | - true 55 | - - id: 3 56 | machine_id: 1 57 | user_id: null 58 | label: test_project root directory. 59 | path: "/mnt/projects/test_project" 60 | bookmarks: {} 61 | tree: 62 | - shots: [] 63 | - audio: [] 64 | - preproduction: 65 | - mood: [] 66 | - reference: [] 67 | - edit: [] 68 | - true 69 | - - id: 4 70 | machine_id: 1 71 | user_id: null 72 | label: test_shot_01 root directory. 73 | path: "{shot.project.directory.path}/shots/{shot.name}" 74 | bookmarks: {} 75 | tree: 76 | - _current: 77 | - h265: [] 78 | - png: [] 79 | - webm: [] 80 | - true 81 | - - id: 5 82 | machine_id: 1 83 | user_id: null 84 | label: test_shot_01_v000 root directory. 85 | path: "{shot_version.shot.directory.path}/{shot_version.num}" 86 | bookmarks: {} 87 | tree: 88 | - _in: 89 | - plate: [] 90 | - subsets: [] 91 | - _out: 92 | - representations: 93 | - exr: [] 94 | - png: [] 95 | - mov: [] 96 | - true 97 | - - id: 6 98 | machine_id: 1 99 | user_id: null 100 | label: natron 2.3.15 root directory on machine 1. 101 | path: "/opt/Natron-2.3.15/bin/natron" 102 | bookmarks: {} 103 | tree: 104 | - _in: 105 | - plate: [] 106 | - subsets: [] 107 | - _out: 108 | - exr: [] 109 | - png: [] 110 | - subsets: [] 111 | - true 112 | - - id: 7 113 | machine_id: 1 114 | user_id: 2 115 | label: 2nd test_user's working directory on machine 1. 116 | path: "/home/jacob/working_dir" 117 | tree: 118 | - current_assignments: [] 119 | - ".backups": [] 120 | bookmarks: 121 | backups: ".backups" 122 | current_assignments: current_assignments 123 | - true 124 | - - id: 8 125 | machine_id: 1 126 | user_id: null 127 | label: shot version tool-subdirectory. 128 | path: "/home/jacob/working_dir" 129 | tree: 130 | - current_assignments: [] 131 | - ".backups": [] 132 | bookmarks: 133 | backups: ".backups" 134 | current_assignments: current_assignments 135 | - true 136 | Timeline: 137 | - - id: 1 138 | label: example label for a timeline. 139 | user_id: null 140 | otio: 141 | OTIO_SCHEMA: Timeline.1 142 | metadata: {} 143 | name: test_project 144 | global_start_time: 145 | tracks: 146 | OTIO_SCHEMA: Stack.1 147 | metadata: {} 148 | name: tracks 149 | source_range: 150 | effects: [] 151 | markers: [] 152 | children: [] 153 | - true 154 | - - id: 2 155 | label: dummy timeline to assign temporarily during tests. 156 | user_id: 2 157 | otio: 158 | OTIO_SCHEMA: Timeline.1 159 | metadata: {} 160 | name: test_project 161 | global_start_time: 162 | tracks: 163 | OTIO_SCHEMA: Stack.1 164 | metadata: {} 165 | name: tracks 166 | source_range: 167 | effects: [] 168 | markers: [] 169 | children: [] 170 | - true 171 | Project: 172 | - - id: 1 173 | name: test_project 174 | directory_id: 3 175 | timeline_id: 1 176 | # episodes: [] 177 | # sequences: [] 178 | settings: 179 | project_directory: "/mnt/projects/{project.name}" 180 | project_directory_tree: 181 | - shots: [] 182 | - audio: [] 183 | - preproduction: 184 | - mood: [] 185 | - reference: [] 186 | - edit: [] 187 | frame_sequence_re: "(\\w+\\W)(\\#+)(.+)" 188 | shot_directory: "{shot.project.directory.path}/shots/{shot.name}" 189 | shot_directory_tree: 190 | - _current: 191 | - h265: [] 192 | - png: [] 193 | - webm: [] 194 | shot_version_directory: "{shot_version.shot.directory.path}/{shot_version.num}" 195 | shot_version_directory_tree: 196 | - _in: 197 | - plate: [] 198 | - subsets: [] 199 | - _out: 200 | - representations: 201 | - exr: [] 202 | - png: [] 203 | - mov: [] 204 | shot_version_bookmarks: 205 | png_representation: representations/png_sequence/_out/png/{shot_version.full_name}.####.png 206 | - true 207 | Settings2D: 208 | - - id: 1 209 | project_id: 1 210 | label: Full 4K @30FPS 211 | width: 4096 212 | height: 2048 213 | rate: 30 214 | color_profile: null 215 | - true 216 | - - id: 2 217 | project_id: 1 218 | label: Full 2K @60FPS 219 | width: 4096 220 | height: 2048 221 | rate: 60 222 | color_profile: null 223 | - true 224 | Tool: 225 | - - id: 1 226 | name: natron 227 | description: "" 228 | - true 229 | Shot: 230 | - - id: 1 231 | name: test_shot_01 232 | project_id: 1 233 | directory_id: 3 234 | episode_id: null 235 | sequence_id: null 236 | start_frame_in_parent: 0 237 | track_index: 1 238 | otio: 239 | OTIO_SCHEMA: Clip.1 240 | metadata: {} 241 | name: test_shot_01 242 | source_range: 243 | effects: [] 244 | markers: [] 245 | media_reference: 246 | OTIO_SCHEMA: ImageSequenceReference.1 247 | metadata: {} 248 | name: '' 249 | available_range: 250 | OTIO_SCHEMA: TimeRange.1 251 | duration: 252 | OTIO_SCHEMA: RationalTime.1 253 | rate: 30.0 254 | value: 1 255 | start_time: 256 | OTIO_SCHEMA: RationalTime.1 257 | rate: 30.0 258 | value: 1 259 | target_url_base: '' 260 | name_prefix: test_project_test_shot_v000. 261 | name_suffix: ".png" 262 | start_frame: 1 263 | frame_step: 1 264 | rate: 30.0 265 | frame_zero_padding: 4 266 | missing_frame_policy: error 267 | - true 268 | ShotVersion: 269 | - - id: 1 270 | num: 0 271 | shot_id: 1 272 | directory_id: 4 273 | otio: 274 | OTIO_SCHEMA: ImageSequenceReference.1 275 | metadata: {} 276 | name: '' 277 | available_range: 278 | OTIO_SCHEMA: TimeRange.1 279 | duration: 280 | OTIO_SCHEMA: RationalTime.1 281 | rate: 30.0 282 | value: 1 283 | start_time: 284 | OTIO_SCHEMA: RationalTime.1 285 | rate: 30.0 286 | value: 1 287 | target_url_base: '' 288 | name_prefix: test_project_test_shot_v000. 289 | name_suffix: ".png" 290 | start_frame: 1 291 | frame_step: 1 292 | rate: 30.0 293 | frame_zero_padding: 4 294 | missing_frame_policy: error 295 | - true 296 | Assignment: 297 | - - id: 1 298 | user_id: 1 299 | shot_version_id: 1 300 | - true 301 | Context: 302 | - - id: 1 303 | machine_id: 1 304 | assignment_id: 1 305 | - true 306 | - - id: 2 307 | machine_id: 1 308 | assignment_id: 1 309 | - true 310 | ToolVersion: 311 | - - id: 1 312 | string: 2.3.15 313 | tool_id: 1 314 | file_extension: "" 315 | file_types_id: null 316 | - true 317 | ToolVersionInstallation: 318 | - - id: 1 319 | tool_version_id: 1 320 | directory_id: 5 321 | - true 322 | ToolConfig: 323 | - - id: 1 324 | project_id: 1 325 | tool_version_id: 1 326 | directory_id: 7 327 | env: 328 | PYTHONPATH: "/mnt/pipeline_share/python/tools/natron/2.3.15" 329 | PATH: "" 330 | copy_dict: 331 | gizmos: 332 | - "/mnt/pipeline_share/tools/natron/gizmos/natron_gg" 333 | - "{user.working_directory}/tools/natron/gizmos/natron_gg" 334 | - true 335 | -------------------------------------------------------------------------------- /magla/core/data.py: -------------------------------------------------------------------------------- 1 | """MaglaData objects are the universal data transportation and syncing device of `magla`.""" 2 | import sys 3 | try: 4 | from collections.abc import MutableMapping # noqa 5 | except ImportError: 6 | from collections import MutableMapping # noqa 7 | 8 | from pprint import pformat 9 | 10 | from sqlalchemy.orm.exc import NoResultFound 11 | 12 | from ..utils import apply_dict_to_record, record_to_dict, otio_to_dict 13 | from .errors import MaglaError 14 | 15 | 16 | class MaglaDataError(MaglaError): 17 | """An error accured preventing MaglaData to continue.""" 18 | 19 | 20 | class NoRecordFoundError(MaglaDataError): 21 | """No record was found matching given data.""" 22 | 23 | 24 | class CustomDict(MutableMapping): 25 | """A dictionary that applies an arbitrary key-altering function before accessing the keys. 26 | 27 | The MaglaData object serves as the common transport of data within magla. `MaglaData` inherits 28 | from the `MuteableMapping` class giving it unique dot-notated access qualities. 29 | 30 | example: 31 | ``` 32 | # getting 33 | data["key"] 34 | data.key 35 | 36 | # setting 37 | data["key"] = "new_value" 38 | data.key = "new_value" 39 | 40 | # traditional dict methods also available 41 | data.update({"key":"new_value"}) 42 | ``` 43 | 44 | In addition `MaglaData` objects have direct access to the `SQLAlchemy` session to sync and 45 | validate their data with what's on record. 46 | 47 | Attributes 48 | ---------- 49 | __invalid_key_names : list 50 | List of native attribute names so they don't get overwritten by user-created keys 51 | _store : dict 52 | User-created keys and values 53 | """ 54 | 55 | def __init__(self, data=None, **kwargs): 56 | """Initialize with given data. 57 | 58 | Parameters 59 | ---------- 60 | data : dict, optional 61 | User-defined dict, by default None 62 | """ 63 | # create snapshot of core attributes so they don't get overwritten 64 | self.__invalid_key_names = list(dir(super(CustomDict, self)) + dir(self)) 65 | self._store = data or dict() 66 | 67 | def __getitem__(self, key): 68 | return self._store[key] 69 | 70 | def __setitem__(self, key, value): 71 | # self._validate_key(key) 72 | self._store[key] = value 73 | self.__dict__[key] = value 74 | 75 | def __delitem__(self, key): 76 | # self._validate_key(key) 77 | del self._store[key] 78 | del self.__dict__[key] 79 | 80 | def __iter__(self): 81 | return iter(self._store) 82 | 83 | def __len__(self): 84 | return len(self._store) 85 | 86 | def _validate_key(self, key): 87 | """Invoke when something changes in the dict. 88 | 89 | Returns 90 | ------- 91 | str 92 | The key that was altered 93 | """ 94 | if key in self.__invalid_key_names: 95 | msg = "Can't use name: '{}' as a key - it's reserved by this object!".format( 96 | key) 97 | raise MaglaDataError(msg) # MaglaDataError({"message": msg}) 98 | 99 | return key 100 | 101 | def get(self, *args, **kwargs): 102 | """Default dict.get override to make sure it only retrieves from `_store`. 103 | 104 | Returns 105 | ------- 106 | * 107 | Whatever was stored for given key 108 | """ 109 | return self._store.get(*args, **kwargs) 110 | 111 | 112 | class MaglaData(CustomDict): 113 | """An magla-specific interface for local and remote data. 114 | 115 | Attributes 116 | ---------- 117 | _schema : magla.db.entity.MaglaEntity 118 | The associated mapped entity (defined in 'magla/db/') 119 | __record : sqlalchemy.ext.declarative.api.Base 120 | The returned record from the session query (containing data directly from backend) 121 | __session : sqlalchemy.orm.session.Session 122 | https://docs.sqlalchemy.org/en/13/orm/session_basics.html 123 | """ 124 | 125 | def __init__(self, schema, data, session, *args, **kwargs): 126 | """Initialize with `magla.db` schema, `data` to query with, and `session` 127 | 128 | Parameters 129 | ---------- 130 | schema : magla.db.entity.MaglaEntity 131 | The associated mapped entity (defined in 'magla/db/') 132 | data : dict 133 | data to query with 134 | session : sqlalchemy.orm.session.Session 135 | The `SQLAlchemy` session managing all of our transactions 136 | 137 | Raises 138 | ------ 139 | MaglaDataError 140 | An error accured preventing MaglaData to continue. 141 | NoRecordFoundError 142 | No record was found matching given data. 143 | """ 144 | if not isinstance(data, dict): 145 | msg = "'data' must be a python dict! Received: {0}".format( 146 | type(data)) 147 | raise MaglaDataError(msg) 148 | self._schema = schema 149 | self.__record = None 150 | self.__session = session 151 | super(MaglaData, self).__init__(data, *args, **kwargs) 152 | 153 | # attempt to pull from DB 154 | try: 155 | self.pull() 156 | except NoResultFound as err: 157 | raise NoRecordFoundError("No '{0}' record found for: {1}".format( 158 | schema.__entity_name__, data)) 159 | 160 | def __eq__(self, other): 161 | return self._store == other.__dict__ 162 | 163 | def __repr__(self): 164 | keys = [key for key in sorted( 165 | self.__dict__) if not (key).startswith("_")] 166 | items = ("{}={!r}".format(k, self.__dict__[k]) for k in keys) 167 | return "<{}: {}>".format(self._schema.__entity_name__, ", ".join(items)) 168 | 169 | def __setattr__(self, name, val): 170 | super(MaglaData, self).__setattr__(name, val) 171 | if hasattr(self, "_store") and not name.startswith("_"): 172 | self.__setitem__(name, val) 173 | 174 | @classmethod 175 | def from_record(cls, record, otio_as_dict): 176 | """Instantiate a `MaglaData` object from a `SQLAlchemy` record. 177 | 178 | Parameters 179 | ---------- 180 | record : sqlalchemy.ext.declarative.api.Base 181 | A record object from a `SQLAlchemy` query containing data 182 | 183 | Returns 184 | ------- 185 | magla.core.data.MaglaData 186 | New `MaglaData` object synced with backend data 187 | """ 188 | return cls(record_to_dict(record, otio_as_dict), record.__class__) 189 | 190 | @property 191 | def session(self): 192 | """Retrieve session. 193 | 194 | Returns 195 | ------- 196 | sqlalchemy.orm.session.Session 197 | The `SQLAlchemy` session managing all of our transactions 198 | """ 199 | return self.__session 200 | 201 | @property 202 | def record(self): 203 | """Retrieve record. 204 | 205 | Returns 206 | ------- 207 | sqlalchemy.ext.declarative.api.Base 208 | The returned record from the session query (containing data directly from backend) 209 | """ 210 | return self.__record 211 | 212 | def dict(self): 213 | return self._store 214 | 215 | def has(self, key, type_=None): 216 | """Determine whether or not a key exists and it's value is of a certain type. 217 | 218 | Parameters 219 | ---------- 220 | key : str 221 | Target key 222 | type_ : *, optional 223 | Type to valid with, by default None 224 | 225 | Returns 226 | ------- 227 | Bool 228 | True if validated, False if not 229 | """ 230 | valid = True 231 | if type_: 232 | valid = isinstance(self._store.get(key, None), type_) 233 | return key in self and self.get(key, None) and valid 234 | 235 | def pprint(self): 236 | """Output formatted data to `stdout`.""" 237 | sys.stdout.write("{0}\n".format(pformat(self.dict(), width=1))) 238 | 239 | def pull(self): 240 | """Pull and update data from backend. 241 | 242 | Returns 243 | ------- 244 | sqlalchemy.ext.declarative.api.Base 245 | The record retrieved from the query 246 | 247 | Raises 248 | ------ 249 | NoRecordFoundError 250 | No record was found matching given data. 251 | """ 252 | query_dict = otio_to_dict(self._store) 253 | record = self.session.query(self._schema).filter_by(**query_dict).first() 254 | if not record: 255 | raise NoRecordFoundError( 256 | "No record found for: {}".format(query_dict)) 257 | # apply to current state 258 | self.__record = record 259 | backend_data = record_to_dict(record, otio_as_dict=False) 260 | self.update(backend_data) 261 | return record 262 | 263 | def push(self): 264 | """Push local data to update backend. 265 | 266 | Returns 267 | ------- 268 | sqlalchemy.ext.declarative.api.Base 269 | The record retrieved from the update 270 | """ 271 | temp = self.__record 272 | self.__record = apply_dict_to_record(self.__record, self._store, otio_as_dict=True) 273 | self.session.commit() 274 | self.__record = temp 275 | 276 | def validate_key(self, key, value=None, delete=False): 277 | """Make sure key name will not overwrite any native attributes. 278 | 279 | Parameters 280 | ---------- 281 | key : str 282 | Key name 283 | value : *, optional 284 | Value for key, by default None 285 | delete : bool, optional 286 | delete the key if valid, by default False 287 | 288 | Returns 289 | ------- 290 | str 291 | Key that was affected 292 | """ 293 | super(MaglaData, self)._validate_key(key) 294 | return self._sync(key, value, delete) 295 | --------------------------------------------------------------------------------