Added BAM-packing, requires version of Blender that includes BAM 1.1.1
This commit is contained in:
parent
8151b952b9
commit
35d4f85010
@ -181,7 +181,7 @@ class BlenderCloudPreferences(AddonPreferences):
|
|||||||
# This assumption is true for the Blender Institute, but we should allow other setups too.
|
# This assumption is true for the Blender Institute, but we should allow other setups too.
|
||||||
flamenco_job_file_path = StringProperty(
|
flamenco_job_file_path = StringProperty(
|
||||||
name='Job file path',
|
name='Job file path',
|
||||||
description='Path where to store job files',
|
description='Path where to store job files, should be accesible for Workers too',
|
||||||
subtype='DIR_PATH',
|
subtype='DIR_PATH',
|
||||||
default='/render/_flamenco/storage')
|
default='/render/_flamenco/storage')
|
||||||
|
|
||||||
@ -351,14 +351,15 @@ class BlenderCloudPreferences(AddonPreferences):
|
|||||||
else:
|
else:
|
||||||
row_buttons.label('Fetching available managers.')
|
row_buttons.label('Fetching available managers.')
|
||||||
|
|
||||||
|
path_box = flamenco_box.row(align=True)
|
||||||
|
path_box.prop(self, 'flamenco_job_file_path')
|
||||||
|
path_box.operator('flamenco.open_job_file_path', text='', icon='DISK_DRIVE')
|
||||||
|
|
||||||
# TODO: make a reusable way to select projects, and use that for Attract and Flamenco.
|
# TODO: make a reusable way to select projects, and use that for Attract and Flamenco.
|
||||||
note_box = flamenco_box.column(align=True)
|
note_box = flamenco_box.column(align=True)
|
||||||
note_box.label('NOTE: For now, Flamenco uses the same project as Attract.')
|
note_box.label('NOTE: For now, Flamenco uses the same project as Attract.')
|
||||||
note_box.label('This will change in a future version of the add-on.')
|
note_box.label('This will change in a future version of the add-on.')
|
||||||
|
|
||||||
flamenco_box.prop(self, 'flamenco_job_file_path')
|
|
||||||
note_box = flamenco_box.column(align=True)
|
|
||||||
note_box.label('NOTE: Flamenco assumes the workers can use this path too.')
|
|
||||||
|
|
||||||
|
|
||||||
class PillarCredentialsUpdate(pillar.PillarOperatorMixin,
|
class PillarCredentialsUpdate(pillar.PillarOperatorMixin,
|
||||||
|
@ -20,7 +20,10 @@
|
|||||||
|
|
||||||
The preferences are managed blender.py, the rest of the Flamenco-specific stuff is here.
|
The preferences are managed blender.py, the rest of the Flamenco-specific stuff is here.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import typing
|
||||||
|
|
||||||
import bpy
|
import bpy
|
||||||
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
|
from bpy.types import AddonPreferences, Operator, WindowManager, Scene, PropertyGroup
|
||||||
@ -116,6 +119,7 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
|
|||||||
"""Performs a Blender render on Flamenco."""
|
"""Performs a Blender render on Flamenco."""
|
||||||
bl_idname = 'flamenco.render'
|
bl_idname = 'flamenco.render'
|
||||||
bl_label = 'Render on Flamenco'
|
bl_label = 'Render on Flamenco'
|
||||||
|
bl_description = __doc__.rstrip('.')
|
||||||
|
|
||||||
stop_upon_exception = True
|
stop_upon_exception = True
|
||||||
_log = logging.getLogger('bpy.ops.%s' % bl_idname)
|
_log = logging.getLogger('bpy.ops.%s' % bl_idname)
|
||||||
@ -124,36 +128,100 @@ class FLAMENCO_OT_render(async_loop.AsyncModalOperatorMixin,
|
|||||||
if not await self.authenticate(context):
|
if not await self.authenticate(context):
|
||||||
return
|
return
|
||||||
|
|
||||||
import os.path
|
|
||||||
from ..blender import preferences
|
|
||||||
from pillarsdk import exceptions as sdk_exceptions
|
from pillarsdk import exceptions as sdk_exceptions
|
||||||
|
from ..blender import preferences
|
||||||
|
|
||||||
|
filepath = Path(context.blend_data.filepath)
|
||||||
|
|
||||||
|
# BAM-pack the files to the destination directory.
|
||||||
|
outfile, missing_sources = await self.bam_pack(filepath)
|
||||||
|
if not outfile:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create the job at Flamenco Server.
|
||||||
prefs = preferences()
|
prefs = preferences()
|
||||||
scene = context.scene
|
scene = context.scene
|
||||||
|
settings = {"blender_cmd": "{blender}",
|
||||||
|
"chunk_size": scene.flamenco_render_chunk_size,
|
||||||
|
"filepath": str(outfile),
|
||||||
|
"frames": scene.flamenco_render_frame_range}
|
||||||
try:
|
try:
|
||||||
await create_job(self.user_id,
|
job_info = await create_job(self.user_id,
|
||||||
prefs.attract_project.project,
|
prefs.attract_project.project,
|
||||||
prefs.flamenco_manager.manager,
|
prefs.flamenco_manager.manager,
|
||||||
'blender-render',
|
'blender-render',
|
||||||
{
|
settings,
|
||||||
"blender_cmd": "{blender}",
|
'Render %s' % filepath.name)
|
||||||
"chunk_size": scene.flamenco_render_chunk_size,
|
|
||||||
"filepath": context.blend_data.filepath,
|
|
||||||
"frames": scene.flamenco_render_frame_range
|
|
||||||
},
|
|
||||||
'Render %s' % os.path.basename(context.blend_data.filepath))
|
|
||||||
except sdk_exceptions.ResourceInvalid as ex:
|
except sdk_exceptions.ResourceInvalid as ex:
|
||||||
self.report({'ERROR'}, 'Error creating Flamenco job: %s' % ex)
|
self.report({'ERROR'}, 'Error creating Flamenco job: %s' % ex)
|
||||||
|
self.quit()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Store the job ID in a file in the output dir.
|
||||||
|
with open(str(outfile.parent / 'jobinfo.json'), 'w', encoding='utf8') as outfile:
|
||||||
|
import json
|
||||||
|
|
||||||
|
job_info['missing_files'] = [str(mf) for mf in missing_sources]
|
||||||
|
json.dump(job_info, outfile, sort_keys=True, indent=4)
|
||||||
|
|
||||||
|
# Do a final report.
|
||||||
|
if missing_sources:
|
||||||
|
names = (ms.name for ms in missing_sources)
|
||||||
|
self.report({'WARNING'}, 'Flamenco job created with missing files: %s' %
|
||||||
|
'; '.join(names))
|
||||||
else:
|
else:
|
||||||
self.report({'INFO'}, 'Flamenco job created.')
|
self.report({'INFO'}, 'Flamenco job created.')
|
||||||
self.quit()
|
self.quit()
|
||||||
|
|
||||||
|
async def bam_pack(self, filepath: Path) -> (typing.Optional[Path], typing.List[Path]):
|
||||||
|
"""BAM-packs the blendfile to the destination directory.
|
||||||
|
|
||||||
|
Returns the path of the destination blend file.
|
||||||
|
|
||||||
|
:param filepath: the blend file to pack (i.e. the current blend file)
|
||||||
|
:returns: the destination blend file, or None if there were errors BAM-packing,
|
||||||
|
and a list of missing paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from ..blender import preferences
|
||||||
|
from . import bam_interface
|
||||||
|
|
||||||
|
prefs = preferences()
|
||||||
|
|
||||||
|
# Create a unique directory that is still more or less identifyable.
|
||||||
|
# This should work better than a random ID.
|
||||||
|
# BAM doesn't like output directories that end in '.blend'.
|
||||||
|
unique_dir = '%s-%s-%s' % (datetime.now().isoformat('-').replace(':', ''),
|
||||||
|
self.db_user['username'],
|
||||||
|
filepath.name.replace('.blend', ''))
|
||||||
|
outdir = Path(prefs.flamenco_job_file_path) / unique_dir
|
||||||
|
outfile = outdir / filepath.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
outdir.mkdir(parents=True)
|
||||||
|
except Exception as ex:
|
||||||
|
self.log.exception('Unable to create output path %s', outdir)
|
||||||
|
self.report({'ERROR'}, 'Unable to create output path: %s' % ex)
|
||||||
|
self.quit()
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
try:
|
||||||
|
missing_sources = await bam_interface.bam_copy(filepath, outfile)
|
||||||
|
except bam_interface.CommandExecutionError as ex:
|
||||||
|
self.log.exception('Unable to execute BAM pack')
|
||||||
|
self.report({'ERROR'}, 'Unable to execute BAM pack: %s' % ex)
|
||||||
|
self.quit()
|
||||||
|
return None, []
|
||||||
|
|
||||||
|
return outfile, missing_sources
|
||||||
|
|
||||||
|
|
||||||
class FLAMENCO_OT_scene_to_frame_range(Operator):
|
class FLAMENCO_OT_scene_to_frame_range(Operator):
|
||||||
"""Sets the scene frame range as the Flamenco render frame range."""
|
"""Sets the scene frame range as the Flamenco render frame range."""
|
||||||
bl_idname = 'flamenco.scene_to_frame_range'
|
bl_idname = 'flamenco.scene_to_frame_range'
|
||||||
bl_label = 'Sets the scene frame range as the Flamenco render frame range'
|
bl_label = 'Sets the scene frame range as the Flamenco render frame range'
|
||||||
|
bl_description = __doc__.rstrip('.')
|
||||||
|
|
||||||
def execute(self, context):
|
def execute(self, context):
|
||||||
s = context.scene
|
s = context.scene
|
||||||
@ -161,6 +229,56 @@ class FLAMENCO_OT_scene_to_frame_range(Operator):
|
|||||||
return {'FINISHED'}
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
|
class FLAMENCO_OT_copy_files(Operator,
|
||||||
|
async_loop.AsyncModalOperatorMixin):
|
||||||
|
"""Uses BAM to copy the current blendfile + dependencies to the target directory."""
|
||||||
|
bl_idname = 'flamenco.copy_files'
|
||||||
|
bl_label = 'Copy files to target'
|
||||||
|
bl_description = __doc__.rstrip('.')
|
||||||
|
|
||||||
|
stop_upon_exception = True
|
||||||
|
|
||||||
|
async def async_execute(self, context):
|
||||||
|
from pathlib import Path
|
||||||
|
from . import bam_interface
|
||||||
|
from ..blender import preferences
|
||||||
|
|
||||||
|
missing_sources = await bam_interface.bam_copy(
|
||||||
|
Path(context.blend_data.filepath),
|
||||||
|
Path(preferences().flamenco_job_file_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
if missing_sources:
|
||||||
|
names = (ms.name for ms in missing_sources)
|
||||||
|
self.report({'ERROR'}, 'Missing source files: %s' % '; '.join(names))
|
||||||
|
|
||||||
|
self.quit()
|
||||||
|
|
||||||
|
|
||||||
|
class FLAMENCO_OT_open_job_file_path(Operator):
|
||||||
|
"""Opens the Flamenco job storage path in a file explorer."""
|
||||||
|
bl_idname = 'flamenco.open_job_file_path'
|
||||||
|
bl_label = 'Open in file explorer'
|
||||||
|
bl_description = __doc__.rstrip('.')
|
||||||
|
|
||||||
|
def execute(self, context):
|
||||||
|
import platform
|
||||||
|
import subprocess
|
||||||
|
import os
|
||||||
|
|
||||||
|
from ..blender import preferences
|
||||||
|
|
||||||
|
path = preferences().flamenco_job_file_path
|
||||||
|
if platform.system() == "Windows":
|
||||||
|
os.startfile(path)
|
||||||
|
elif platform.system() == "Darwin":
|
||||||
|
subprocess.Popen(["open", path])
|
||||||
|
else:
|
||||||
|
subprocess.Popen(["xdg-open", path])
|
||||||
|
|
||||||
|
return {'FINISHED'}
|
||||||
|
|
||||||
|
|
||||||
async def create_job(user_id: str,
|
async def create_job(user_id: str,
|
||||||
project_id: str,
|
project_id: str,
|
||||||
manager_id: str,
|
manager_id: str,
|
||||||
@ -168,8 +286,8 @@ async def create_job(user_id: str,
|
|||||||
job_settings: dict,
|
job_settings: dict,
|
||||||
job_name: str = None,
|
job_name: str = None,
|
||||||
*,
|
*,
|
||||||
job_description: str = None) -> str:
|
job_description: str = None) -> dict:
|
||||||
"""Creates a render job at Flamenco Server, returning the job ID."""
|
"""Creates a render job at Flamenco Server, returning the job object as dictionary."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from .sdk import Job
|
from .sdk import Job
|
||||||
@ -195,23 +313,38 @@ async def create_job(user_id: str,
|
|||||||
await pillar_call(job.create)
|
await pillar_call(job.create)
|
||||||
|
|
||||||
log.info('Job created succesfully: %s', job._id)
|
log.info('Job created succesfully: %s', job._id)
|
||||||
return job._id
|
return job.to_dict()
|
||||||
|
|
||||||
|
|
||||||
def draw_render_button(self, context):
|
class FLAMENCO_PT_render(bpy.types.Panel):
|
||||||
|
bl_label = "Flamenco Render"
|
||||||
|
bl_space_type = 'PROPERTIES'
|
||||||
|
bl_region_type = 'WINDOW'
|
||||||
|
bl_context = "render"
|
||||||
|
bl_options = {'DEFAULT_CLOSED'}
|
||||||
|
|
||||||
|
def draw(self, context):
|
||||||
layout = self.layout
|
layout = self.layout
|
||||||
|
|
||||||
from ..blender import icon
|
from ..blender import preferences
|
||||||
|
|
||||||
flamenco_box = layout.box()
|
layout.prop(context.scene, 'flamenco_render_chunk_size')
|
||||||
flamenco_box.label('Flamenco', icon_value=icon('CLOUD'))
|
|
||||||
flamenco_box.prop(context.scene, 'flamenco_render_chunk_size')
|
|
||||||
|
|
||||||
frange_row = flamenco_box.row(align=True)
|
labeled_row = layout.split(0.2, align=True)
|
||||||
frange_row.prop(context.scene, 'flamenco_render_frame_range')
|
labeled_row.label('Frame range:')
|
||||||
frange_row.operator('flamenco.scene_to_frame_range', text='', icon='ARROW_LEFTRIGHT')
|
prop_btn_row = labeled_row.row(align=True)
|
||||||
|
prop_btn_row.prop(context.scene, 'flamenco_render_frame_range', text='')
|
||||||
|
prop_btn_row.operator('flamenco.scene_to_frame_range', text='', icon='ARROW_LEFTRIGHT')
|
||||||
|
|
||||||
flamenco_box.operator('flamenco.render', text='Render on Flamenco', icon='RENDER_ANIMATION')
|
labeled_row = layout.split(0.2, align=True)
|
||||||
|
labeled_row.label('Storage:')
|
||||||
|
prop_btn_row = labeled_row.row(align=True)
|
||||||
|
prop_btn_row.label(preferences().flamenco_job_file_path)
|
||||||
|
prop_btn_row.operator(FLAMENCO_OT_open_job_file_path.bl_idname, text='', icon='DISK_DRIVE')
|
||||||
|
|
||||||
|
layout.operator(FLAMENCO_OT_render.bl_idname,
|
||||||
|
text='Render on Flamenco',
|
||||||
|
icon='RENDER_ANIMATION')
|
||||||
|
|
||||||
|
|
||||||
def register():
|
def register():
|
||||||
@ -219,6 +352,9 @@ def register():
|
|||||||
bpy.utils.register_class(FLAMENCO_OT_fmanagers)
|
bpy.utils.register_class(FLAMENCO_OT_fmanagers)
|
||||||
bpy.utils.register_class(FLAMENCO_OT_render)
|
bpy.utils.register_class(FLAMENCO_OT_render)
|
||||||
bpy.utils.register_class(FLAMENCO_OT_scene_to_frame_range)
|
bpy.utils.register_class(FLAMENCO_OT_scene_to_frame_range)
|
||||||
|
bpy.utils.register_class(FLAMENCO_OT_copy_files)
|
||||||
|
bpy.utils.register_class(FLAMENCO_OT_open_job_file_path)
|
||||||
|
bpy.utils.register_class(FLAMENCO_PT_render)
|
||||||
|
|
||||||
scene = bpy.types.Scene
|
scene = bpy.types.Scene
|
||||||
scene.flamenco_render_chunk_size = IntProperty(
|
scene.flamenco_render_chunk_size = IntProperty(
|
||||||
@ -231,11 +367,7 @@ def register():
|
|||||||
description='Frames to render, in "printer range" notation'
|
description='Frames to render, in "printer range" notation'
|
||||||
)
|
)
|
||||||
|
|
||||||
bpy.types.RENDER_PT_render.append(draw_render_button)
|
|
||||||
|
|
||||||
|
|
||||||
def unregister():
|
def unregister():
|
||||||
bpy.types.RENDER_PT_render.remove(draw_render_button)
|
|
||||||
bpy.utils.unregister_module(__name__)
|
bpy.utils.unregister_module(__name__)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
144
blender_cloud/flamenco/bam_interface.py
Normal file
144
blender_cloud/flamenco/bam_interface.py
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
"""BAM packing interface for Flamenco."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
import typing
|
||||||
|
|
||||||
|
# Timeout of the BAM subprocess, in seconds.
|
||||||
|
SUBPROC_READLINE_TIMEOUT = 600
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CommandExecutionError(Exception):
|
||||||
|
"""Raised when there was an error executing a BAM command."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def bam_copy(base_blendfile: Path, target_blendfile: Path) -> typing.List[Path]:
|
||||||
|
"""Uses BAM to copy the given file and dependencies to the target blendfile.
|
||||||
|
|
||||||
|
Due to the way blendfile_pack.py is programmed/structured, we cannot import it
|
||||||
|
and call a function; it has to be run in a subprocess.
|
||||||
|
|
||||||
|
:raises: asyncio.CanceledError if the task was cancelled.
|
||||||
|
:raises: asyncio.TimeoutError if reading a line from the BAM process timed out.
|
||||||
|
:raises: CommandExecutionError if the subprocess failed or output invalid UTF-8.
|
||||||
|
:returns: a list of missing sources; hopefully empty.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
import bpy
|
||||||
|
import io_blend_utils
|
||||||
|
|
||||||
|
args = [
|
||||||
|
bpy.app.binary_path_python,
|
||||||
|
'-m', 'bam.pack',
|
||||||
|
'--input', str(base_blendfile),
|
||||||
|
'--output', str(target_blendfile),
|
||||||
|
'--mode', 'FILE',
|
||||||
|
]
|
||||||
|
|
||||||
|
cmd_to_log = ' '.join(shlex.quote(s) for s in args)
|
||||||
|
log.info('Executing %s', cmd_to_log)
|
||||||
|
|
||||||
|
proc = await asyncio.create_subprocess_exec(
|
||||||
|
*args,
|
||||||
|
env={'PYTHONPATH': io_blend_utils.pythonpath()},
|
||||||
|
stdin=subprocess.DEVNULL,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
missing_sources = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not proc.stdout.at_eof():
|
||||||
|
line = await asyncio.wait_for(proc.stdout.readline(),
|
||||||
|
SUBPROC_READLINE_TIMEOUT)
|
||||||
|
if not line:
|
||||||
|
# EOF received, so let's bail.
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
line = line.decode('utf8')
|
||||||
|
except UnicodeDecodeError as ex:
|
||||||
|
raise CommandExecutionError('Command produced non-UTF8 output, '
|
||||||
|
'aborting: %s' % ex)
|
||||||
|
|
||||||
|
line = line.rstrip()
|
||||||
|
if 'source missing:' in line:
|
||||||
|
path = parse_missing_source(line)
|
||||||
|
missing_sources.append(path)
|
||||||
|
log.warning('Source is missing: %s', path)
|
||||||
|
|
||||||
|
log.info(' %s', line)
|
||||||
|
finally:
|
||||||
|
if proc.returncode is None:
|
||||||
|
# Always wait for the process, to avoid zombies.
|
||||||
|
try:
|
||||||
|
proc.kill()
|
||||||
|
except ProcessLookupError:
|
||||||
|
# The process is already stopped, so killing is impossible. That's ok.
|
||||||
|
log.debug("The process was already stopped, aborting is impossible. That's ok.")
|
||||||
|
await proc.wait()
|
||||||
|
log.info('The process stopped with status code %i', proc.returncode)
|
||||||
|
|
||||||
|
if proc.returncode:
|
||||||
|
raise CommandExecutionError('Process stopped with status %i' % proc.returncode)
|
||||||
|
|
||||||
|
return missing_sources
|
||||||
|
|
||||||
|
|
||||||
|
def parse_missing_source(line: str) -> Path:
|
||||||
|
r"""Parses a "missing source" line into a pathlib.Path.
|
||||||
|
|
||||||
|
>>> parse_missing_source(r" source missing: b'D\xc3\xaffficult \xc3\x9cTF-8 filename'")
|
||||||
|
PosixPath('Dïfficult ÜTF-8 filename')
|
||||||
|
>>> parse_missing_source(r" source missing: b'D\xfffficult Win1252 f\xeflen\xe6me'")
|
||||||
|
PosixPath('D<EFBFBD>fficult Win1252 f<>len<65>me')
|
||||||
|
"""
|
||||||
|
|
||||||
|
_, missing_source = line.split(': ', 1)
|
||||||
|
missing_source_as_bytes = parse_byte_literal(missing_source.strip())
|
||||||
|
|
||||||
|
# The file could originate from any platform, so UTF-8 and the current platform's
|
||||||
|
# filesystem encodings are just guesses.
|
||||||
|
try:
|
||||||
|
missing_source = missing_source_as_bytes.decode('utf8')
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
missing_source = missing_source_as_bytes.decode(sys.getfilesystemencoding())
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
missing_source = missing_source_as_bytes.decode('ascii', errors='replace')
|
||||||
|
|
||||||
|
path = Path(missing_source)
|
||||||
|
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def parse_byte_literal(bytes_literal: str) -> bytes:
|
||||||
|
r"""Parses a repr(bytes) output into a bytes object.
|
||||||
|
|
||||||
|
>>> parse_byte_literal(r"b'D\xc3\xaffficult \xc3\x9cTF-8 filename'")
|
||||||
|
b'D\xc3\xaffficult \xc3\x9cTF-8 filename'
|
||||||
|
>>> parse_byte_literal(r"b'D\xeffficult Win1252 f\xeflen\xe6me'")
|
||||||
|
b'D\xeffficult Win1252 f\xeflen\xe6me'
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Some very basic assertions to make sure we have a proper bytes literal.
|
||||||
|
assert bytes_literal[0] == "b"
|
||||||
|
assert bytes_literal[1] in {'"', "'"}
|
||||||
|
assert bytes_literal[-1] == bytes_literal[1]
|
||||||
|
|
||||||
|
import ast
|
||||||
|
return ast.literal_eval(bytes_literal)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import doctest
|
||||||
|
|
||||||
|
doctest.testmod()
|
Reference in New Issue
Block a user