1
# Copyright (C) 2005, 2006 by Aaron Bentley
3
# This program is free software; you can redistribute it and/or modify
4
# it under the terms of the GNU General Public License as published by
5
# the Free Software Foundation; either version 2 of the License, or
6
# (at your option) any later version.
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
# GNU General Public License for more details.
13
# You should have received a copy of the GNU General Public License
14
# along with this program; if not, write to the Free Software
15
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
19
from bzrlib.bzrdir import BzrDir
20
import bzrlib.bzrdir as bzrdir
21
from bzrlib.errors import (BzrError,
28
from bzrlib.branch import Branch
29
from bzrlib.commit import Commit, NullCommitReporter
30
from bzrlib.commands import Command
31
from bzrlib.option import _global_option, Option
32
from bzrlib.merge import merge_inner
33
from bzrlib.revision import NULL_REVISION
36
from bzrlib.workingtree import WorkingTree
37
from errors import NoPyBaz
41
from pybaz import NameParser as NameParser
42
from pybaz.backends.baz import null_cmd
45
from fai import iter_new_merges, direct_merges
53
import bzrlib.inventory
57
from progress import *
60
BAZ_IMPORT_ROOT = 'TREE_ROOT'
63
class ImportCommitReporter(NullCommitReporter):
65
def escaped(self, escape_count, message):
66
bzrlib.trace.warning("replaced %d control characters in message" %
69
def add_id(files, id=None):
70
"""Adds an explicit id to a list of files.
72
:param files: the name of the file to add an id to
73
:type files: list of str
74
:param id: tag one file using the specified id, instead of generating id
79
args.extend(["--id", id])
85
def make_archive(name, location):
86
pb_location = pybaz.ArchiveLocation(location)
87
pb_location.create_master(pybaz.Archive(name),
88
pybaz.ArchiveLocationParams())
92
>>> q = test_environ()
95
>>> os.path.exists(os.path.join(q, "home", ".arch-params"))
97
>>> teardown_environ(q)
102
saved_dir = os.getcwdu()
103
tdir = tempfile.mkdtemp(prefix="testdir-")
104
os.environ["HOME"] = os.path.join(tdir, "home")
105
os.mkdir(os.environ["HOME"])
106
arch_dir = os.path.join(tdir, "archive_dir")
107
make_archive("test@example.com", arch_dir)
108
work_dir = os.path.join(tdir, "work_dir")
111
pybaz.init_tree(work_dir, "test@example.com/test--test--0")
112
lib_dir = os.path.join(tdir, "lib_dir")
114
pybaz.register_revision_library(lib_dir)
115
pybaz.set_my_id("Test User<test@example.org>")
118
def add_file(path, text, id):
120
>>> q = test_environ()
121
>>> add_file("path with space", "text", "lalala")
122
>>> tree = pybaz.tree_root(".")
123
>>> inv = list(tree.iter_inventory_ids(source=True, both=True))
124
>>> ("x_lalala", "path with space") in inv
126
>>> teardown_environ(q)
128
file(path, "wb").write(text)
132
def add_dir(path, id):
134
>>> q = test_environ()
135
>>> add_dir("path with\(sp) space", "lalala")
136
>>> tree = pybaz.tree_root(".")
137
>>> inv = list(tree.iter_inventory_ids(source=True, both=True))
138
>>> ("x_lalala", "path with\(sp) space") in inv
140
>>> teardown_environ(q)
145
def teardown_environ(tdir):
149
def timport(tree, summary):
150
msg = tree.log_message()
151
msg["summary"] = summary
154
def commit(tree, summary):
156
>>> q = test_environ()
157
>>> tree = pybaz.tree_root(".")
158
>>> timport(tree, "import")
159
>>> commit(tree, "commit")
160
>>> logs = [str(l.revision) for l in tree.iter_logs()]
164
'test@example.com/test--test--0--base-0'
166
'test@example.com/test--test--0--patch-1'
167
>>> teardown_environ(q)
169
msg = tree.log_message()
170
msg["summary"] = summary
173
def commit_test_revisions():
175
>>> q = test_environ()
176
>>> commit_test_revisions()
177
>>> a = pybaz.Archive("test@example.com")
178
>>> revisions = list(a.iter_revisions("test--test--0"))
181
>>> str(revisions[2])
182
'test@example.com/test--test--0--base-0'
183
>>> str(revisions[1])
184
'test@example.com/test--test--0--patch-1'
185
>>> str(revisions[0])
186
'test@example.com/test--test--0--patch-2'
187
>>> teardown_environ(q)
189
tree = pybaz.tree_root(".")
190
add_file("mainfile", "void main(void){}", "mainfile by aaron")
191
timport(tree, "Created mainfile")
192
file("mainfile", "wb").write("or something like that")
193
commit(tree, "altered mainfile")
194
add_file("ofile", "this is another file", "ofile by aaron")
195
commit(tree, "altered mainfile")
198
def commit_more_test_revisions():
200
>>> q = test_environ()
201
>>> commit_test_revisions()
202
>>> commit_more_test_revisions()
203
>>> a = pybaz.Archive("test@example.com")
204
>>> revisions = list(a.iter_revisions("test--test--0"))
207
>>> str(revisions[0])
208
'test@example.com/test--test--0--patch-3'
209
>>> teardown_environ(q)
211
tree = pybaz.tree_root(".")
212
add_file("trainfile", "void train(void){}", "trainfile by aaron")
213
commit(tree, "altered trainfile")
215
class NoSuchVersion(Exception):
216
def __init__(self, version):
217
Exception.__init__(self, "The version %s does not exist." % version)
218
self.version = version
220
def version_ancestry(version):
222
>>> q = test_environ()
223
>>> commit_test_revisions()
224
>>> version = pybaz.Version("test@example.com/test--test--0")
225
>>> ancestors = version_ancestry(version)
226
>>> str(ancestors[0])
227
'test@example.com/test--test--0--base-0'
228
>>> str(ancestors[1])
229
'test@example.com/test--test--0--patch-1'
230
>>> version = pybaz.Version("test@example.com/test--test--0.5")
231
>>> ancestors = version_ancestry(version)
232
Traceback (most recent call last):
233
NoSuchVersion: The version test@example.com/test--test--0.5 does not exist.
234
>>> teardown_environ(q)
237
revision = version.iter_revisions(reverse=True).next()
238
except StopIteration:
242
if not version.exists():
243
raise NoSuchVersion(version)
246
ancestors = list(revision.iter_ancestors(metoo=True))
250
def get_last_revision(branch):
251
last_patch = branch.last_revision()
253
return arch_revision(last_patch)
254
except NotArchRevision:
256
"Directory \"%s\" already exists, and the last revision is not"
257
" an Arch revision (%s)" % (branch.base, last_patch))
259
def do_branch(br_from, to_location, revision_id):
260
"""Derived from branch in builtins."""
264
os.mkdir(to_location)
266
if e.errno == errno.EEXIST:
267
raise UserError('Target directory "%s" already'
268
' exists.' % to_location)
269
if e.errno == errno.ENOENT:
270
raise UserError('Parent of "%s" does not exist.' %
275
br_from.bzrdir.clone(to_location, revision_id)
276
except NoSuchRevision:
278
msg = "The branch %s has no revision %s." % (from_location,
284
def get_remaining_revisions(output_dir, version, reuse_history_from=[]):
287
output_exists = os.path.exists(output_dir)
289
# We are starting from an existing directory, figure out what
290
# the current version is
291
branch = Branch.open(output_dir)
292
last_patch = get_last_revision(branch)
293
if last_patch is None:
294
if branch.last_revision() != None:
295
raise NotPreviousImport(branch.base)
296
elif version is None:
297
version = last_patch.version
298
elif version is None:
299
raise UserError("No version specified, and directory does not exist.")
302
ancestors = version_ancestry(version)
303
if not output_exists and reuse_history_from != []:
304
for ancestor in reversed(ancestors):
305
if last_patch is not None:
306
# found something to copy
308
# try to grab a copy of ancestor
309
# note that is not optimised: we could look for namespace
310
# transitions and only look for the past after the
312
for history_root in reuse_history_from:
313
possible_source = os.path.join(history_root,
314
map_namespace(ancestor.version))
316
source = Branch.open(possible_source)
317
rev_id = revision_id(ancestor)
318
if rev_id in source.revision_history():
319
do_branch(source, output_dir, rev_id)
320
last_patch = ancestor
322
except NotBranchError:
324
except NoSuchVersion, e:
325
raise UserError(str(e))
328
for i in range(len(ancestors)):
329
if ancestors[i] == last_patch:
332
raise UserError("Directory \"%s\" already exists, and the last "
333
"revision (%s) is not in the ancestry of %s" %
334
(output_dir, last_patch, version))
335
# Strip off all of the ancestors which are already present
336
# And get a directory starting with the latest ancestor
337
latest_ancestor = ancestors[i]
338
old_revno = Branch.open(output_dir).revno()
339
ancestors = ancestors[i+1:]
340
return ancestors, old_revno
343
###class Importer(object):
346
### Currently this is used as a parameter object, though more behaviour is
350
### def __init__(self, output_dir, version, fast=False,
351
### verbose=False, dry_run=False, max_count=None,
352
### reuse_history_from=[]):
353
### self.output_dir = output_dir
354
### self.version = version
358
def import_version(output_dir, version, fast=False,
359
verbose=False, dry_run=False, max_count=None,
360
reuse_history_from=[], standalone=True):
362
>>> q = test_environ()
364
Progress bars output to stderr, but doctest does not capture that.
366
>>> old_stderr = sys.stderr
367
>>> sys.stderr = sys.stdout
369
>>> result_path = os.path.join(q, "result")
370
>>> commit_test_revisions()
371
>>> version = pybaz.Version("test@example.com/test--test--0.1")
372
>>> old_ui = bzrlib.ui.ui_factory
373
>>> bzrlib.ui.ui_factory = bzrlib.ui.text.TextUIFactory(
374
... bar_type=bzrlib.progress.DotsProgressBar)
376
>>> import_version('/', version, dry_run=True)
377
Traceback (most recent call last):
378
NotPreviousImport: / is not the location of a previous import.
379
>>> import_version(result_path, version, dry_run=True)
380
Traceback (most recent call last):
381
UserError: The version test@example.com/test--test--0.1 does not exist.
382
>>> version = pybaz.Version("test@example.com/test--test--0")
383
>>> import_version(result_path, version, dry_run=True) #doctest: +ELLIPSIS
384
importing test@example.com/test--test--0 into ...
386
revisions: ..........................................
387
Dry run, not modifying output_dir
389
>>> import_version(result_path, version) #doctest: +ELLIPSIS
390
importing test@example.com/test--test--0 into ...
392
revisions: .....................................................................
395
>>> import_version(result_path, version) #doctest: +ELLIPSIS
396
Tree is up-to-date with test@example.com/test--test--0--patch-2
397
>>> commit_more_test_revisions()
398
>>> import_version(result_path, version) #doctest: +ELLIPSIS
399
importing test@example.com/test--test--0 into ...
400
revisions: ....................................................
403
>>> bzrlib.ui.ui_factory = old_ui
404
>>> sys.stderr = old_stderr
405
>>> teardown_environ(q)
407
progress_bar = bzrlib.ui.ui_factory.nested_progress_bar()
410
ancestors, old_revno = get_remaining_revisions(output_dir, version,
412
except NotBranchError, e:
413
raise NotPreviousImport(e.path)
414
if old_revno is None and len(ancestors) == 0:
415
progress_bar.note('Version %s has no revisions.' % version)
417
if len(ancestors) == 0:
418
last_revision = get_last_revision(Branch.open(output_dir))
419
progress_bar.note('Tree is up-to-date with %s' % last_revision)
422
progress_bar.note("importing %s into %s" % (version, output_dir))
424
tempdir = tempfile.mkdtemp(prefix="baz2bzr-",
425
dir=os.path.dirname(output_dir))
427
wt = WorkingTree.open(output_dir)
428
except (NotBranchError, NoWorkingTree):
431
for result in iter_import_version(output_dir, ancestors, tempdir,
433
fast=fast, verbose=verbose, dry_run=dry_run,
434
max_count=max_count, standalone=standalone):
435
show_progress(progress_bar, result)
437
progress_bar.note('Dry run, not modifying output_dir')
440
# Update the working tree of the branch
442
wt = WorkingTree.open(output_dir)
443
except NoWorkingTree:
446
wt.set_last_revision(wt.branch.last_revision())
447
wt.set_root_id(BAZ_IMPORT_ROOT)
452
progress_bar.note('Cleaning up')
453
shutil.rmtree(tempdir)
454
progress_bar.note("Import complete.")
456
progress_bar.finished()
458
class UserError(BzrCommandError):
459
def __init__(self, message):
460
"""Exception to throw when a user makes an impossible request
461
:param message: The message to emit when printing this exception
462
:type message: string
464
BzrCommandError.__init__(self, message)
466
class NotPreviousImport(UserError):
467
def __init__(self, path):
468
UserError.__init__(self, "%s is not the location of a previous import."
472
def revision_id(arch_revision):
474
Generate a Bzr revision id from an Arch revision id. 'x' in the id
475
designates a revision imported with an experimental algorithm. A number
476
would indicate a particular standardized version.
478
:param arch_revision: The Arch revision to generate an ID for.
480
>>> revision_id(pybaz.Revision("you@example.com/cat--br--0--base-0"))
481
'Arch-1:you@example.com%cat--br--0--base-0'
483
return "Arch-1:%s" % str(arch_revision).replace('/', '%')
485
class NotArchRevision(Exception):
486
def __init__(self, revision_id):
487
msg = "The revision id %s does not look like it came from Arch."\
489
Exception.__init__(self, msg)
491
def arch_revision(revision_id):
493
>>> str(arch_revision("Arch-1:jrandom@example.com%test--test--0"))
494
Traceback (most recent call last):
495
NotArchRevision: The revision id Arch-1:jrandom@example.com%test--test--0 does not look like it came from Arch.
496
>>> str(arch_revision("Arch-1:jrandom@example.com%test--test--0--base-5"))
497
Traceback (most recent call last):
498
NotArchRevision: The revision id Arch-1:jrandom@example.com%test--test--0--base-5 does not look like it came from Arch.
499
>>> str(arch_revision("Arch-1:jrandom@example.com%test--test--0--patch-5"))
500
'jrandom@example.com/test--test--0--patch-5'
502
if revision_id is None:
504
if revision_id[:7] != 'Arch-1:':
505
raise NotArchRevision(revision_id)
508
return pybaz.Revision(revision_id[7:].replace('%', '/'))
509
except pybaz.errors.NamespaceError, e:
510
raise NotArchRevision(revision_id)
513
def create_shared_repository(output_dir):
514
bd = bzrdir.BzrDirMetaFormat1().initialize(output_dir)
515
bd.create_repository(shared=True)
517
def create_branch(output_dir):
519
bd = bzrdir.BzrDirMetaFormat1().initialize(output_dir)
520
return bd.create_branch()
523
def create_checkout(source, to_location, revision_id=None):
524
checkout = bzrdir.BzrDirMetaFormat1().initialize(to_location)
525
bzrlib.branch.BranchReferenceFormat().initialize(checkout, source)
526
return checkout.create_workingtree(revision_id)
529
def create_checkout_metadata(source, to_location, revision_id=None):
530
if revision_id is None:
531
revision_id = source.last_revision()
532
wt = create_checkout(source, to_location, NULL_REVISION)
533
wt.set_last_revision(revision_id)
534
if revision_id not in (NULL_REVISION, None):
535
wt._write_inventory(wt.basis_tree().inventory)
539
def iter_import_version(output_dir, ancestors, tempdir, pb, fast=False,
540
verbose=False, dry_run=False, max_count=None,
544
# Uncomment this for testing, it basically just has baz2bzr only update
545
# 5 patches at a time
547
ancestors = ancestors[:max_count]
549
# Not sure if I want this output. basically it tells you ahead of time
550
# what it is going to do, but then later it tells you as it is doing it.
551
# what probably would be best would be to collapse it into ranges, so that
552
# this gives the simple view, and then later it gives the blow by blow.
554
# print 'Adding the following revisions:'
555
# for a in ancestors:
558
previous_version=None
559
missing_ancestor = None
561
dry_output_dir = os.path.join(tempdir, 'od')
562
if os.path.exists(output_dir):
563
shutil.copytree(output_dir, dry_output_dir)
564
output_dir = dry_output_dir
566
if os.path.exists(output_dir):
567
target_branch = Branch.open(output_dir)
570
wt = BzrDir.create_standalone_workingtree(output_dir)
571
target_branch = wt.branch
573
target_branch = create_branch(output_dir)
575
for i in range(len(ancestors)):
576
revision = ancestors[i]
577
rev_id = revision_id(revision)
580
version = str(revision.version)
581
if version != previous_version:
582
pb.note('On version: %s' % version)
583
yield Progress(str(revision.patchlevel), i, len(ancestors))
584
previous_version = version
586
yield Progress("revisions", i, len(ancestors))
588
if target_branch.repository.has_revision(rev_id):
589
target_branch.append_revision(rev_id)
592
revdir = os.path.join(tempdir, "rd")
594
tree, baz_inv, log = get_revision(revdir, revision)
595
except pybaz.errors.ExecProblem, e:
596
if ("%s" % e.args).find('could not connect') == -1:
598
missing_ancestor = revision
600
pb.note("unable to access ancestor %s, making into a merge."
603
target_tree = create_checkout_metadata(target_branch, revdir)
604
branch = target_tree.branch
606
old = os.path.join(revdir, ".bzr")
607
new = os.path.join(tempdir, ".bzr")
609
baz_inv, log = apply_revision(tree, revision)
611
target_tree = WorkingTree.open(revdir)
612
branch = target_tree.branch
613
# cached so we can delete the log
615
log_summary = log.summary
616
log_description = log.description
617
is_continuation = log.continuation_of is not None
618
log_creator = log.creator
619
direct_merges = get_direct_merges(revdir, revision, log)
621
timestamp = email.Utils.mktime_tz(log_date + (0,))
622
if log_summary is None:
624
# log_descriptions of None and "" are ignored.
625
if not is_continuation and log_description:
626
log_message = "\n".join((log_summary, log_description))
628
log_message = log_summary
629
target_tree.lock_write()
633
# if we want it to be in revision-history, do that here.
634
target_tree.set_parent_ids([revision_id(missing_ancestor)],
635
allow_leftmost_as_ghost=True)
636
missing_ancestor = None
637
for merged_rev in direct_merges:
638
target_tree.add_pending_merge(revision_id(merged_rev))
639
target_tree.set_root_id(BAZ_IMPORT_ROOT)
640
target_tree.set_inventory(baz_inv)
641
commitobj = Commit(reporter=ImportCommitReporter())
642
commitobj.commit(working_tree=target_tree,
643
message=log_message.decode('ascii', 'replace'),
644
verbose=False, committer=log_creator,
645
timestamp=timestamp, timezone=0, rev_id=rev_id,
650
yield Progress("revisions", len(ancestors), len(ancestors))
652
def get_direct_merges(revdir, revision, log):
653
continuation = log.continuation_of
654
previous_version = revision.version
655
if pybaz.WorkingTree(revdir).tree_version != previous_version:
656
pybaz.WorkingTree(revdir).set_tree_version(previous_version)
657
log_path = "%s/{arch}/%s/%s/%s/%s/patch-log/%s" % (revdir,
658
revision.category.nonarch, revision.branch.nonarch,
659
revision.version.nonarch, revision.archive, revision.patchlevel)
660
temp_path = tempfile.mktemp(dir=os.path.dirname(revdir))
661
os.rename(log_path, temp_path)
662
merges = list(iter_new_merges(revdir, revision.version))
663
direct = direct_merges(merges, [continuation])
664
os.rename(temp_path, log_path)
667
def unlink_unversioned(wt):
668
for unversioned in wt.extras():
669
path = wt.abspath(unversioned)
670
if os.path.isdir(path):
675
def get_log(tree, revision):
676
log = pybaz.Patchlog(revision, tree=tree)
677
assert str(log.revision) == str(revision), (log.revision, revision)
680
def get_revision(revdir, revision):
681
tree = revision.get(revdir)
682
log = get_log(tree, revision)
684
return tree, bzr_inventory_data(tree), log
685
except BadFileKind, e:
686
raise UserError("Cannot convert %s because %s is a %s" %
687
(revision,e.path, e.kind))
690
def apply_revision(tree, revision):
692
log = get_log(tree, revision)
694
return bzr_inventory_data(tree), log
695
except BadFileKind, e:
696
raise UserError("Cannot convert %s because %s is a %s" %
697
(revision,e.path, e.kind))
700
class BadFileKind(Exception):
701
"""The file kind is not permitted in bzr inventories"""
702
def __init__(self, tree_root, path, kind):
703
self.tree_root = tree_root
706
Exception.__init__(self, "File %s is of forbidden type %s" %
707
(os.path.join(tree_root, path), kind))
710
def bzr_inventory_data(tree):
711
inv_iter = tree.iter_inventory_ids(source=True, both=True)
713
for arch_id, path in inv_iter:
714
bzr_file_id = map_file_id(arch_id)
715
inv_map[path] = bzr_file_id
718
for path, file_id in inv_map.iteritems():
719
full_path = os.path.join(tree, path)
720
kind = bzrlib.osutils.file_kind(full_path)
721
if kind not in ("file", "directory", "symlink"):
722
raise BadFileKind(tree, path, kind)
723
parent_dir = os.path.dirname(path)
725
parent_id = inv_map[parent_dir]
727
parent_id = bzrlib.inventory.ROOT_ID
728
bzr_inv.append((path, file_id, parent_id, kind))
733
def baz_import_branch(to_location, from_branch, fast, max_count, verbose,
734
dry_run, reuse_history_list):
735
to_location = os.path.realpath(str(to_location))
736
if from_branch is not None:
738
from_branch = pybaz.Version(from_branch)
739
except pybaz.errors.NamespaceError:
740
print "%s is not a valid Arch branch." % from_branch
742
if reuse_history_list is None:
743
reuse_history_list = []
744
import_version(to_location, from_branch,
746
reuse_history_from=reuse_history_list)
749
class NotInABranch(Exception):
750
def __init__(self, path):
751
Exception.__init__(self, "%s is not in a branch." % path)
756
def baz_import(to_root_dir, from_archive, verbose=False, reuse_history_list=[],
758
if reuse_history_list is None:
759
reuse_history_list = []
760
to_root = str(os.path.realpath(to_root_dir))
761
if not os.path.exists(to_root):
763
if prefixes is not None:
764
prefixes = prefixes.split(':')
765
import_archive(to_root, from_archive, verbose,
766
reuse_history_list, prefixes=prefixes)
769
def import_archive(to_root, from_archive, verbose,
770
reuse_history_from=[], standalone=False,
772
def selected(version):
776
for prefix in prefixes:
777
if version.nonarch.startswith(prefix):
780
real_to = os.path.realpath(to_root)
781
history_locations = [real_to] + reuse_history_from
782
if standalone is False:
784
bd = BzrDir.open(to_root)
786
except NotBranchError:
787
create_shared_repository(to_root)
788
except NoRepositoryPresent:
789
raise BzrCommandError("Can't create repository at existing branch.")
790
versions = list(pybaz.Archive(str(from_archive)).iter_versions())
791
progress_bar = bzrlib.ui.ui_factory.nested_progress_bar()
793
for num, version in enumerate(versions):
794
progress_bar.update("Branch", num, len(versions))
795
if not selected(version):
796
print "Skipping %s" % version
798
target = os.path.join(to_root, map_namespace(version))
799
if not os.path.exists(os.path.dirname(target)):
800
os.makedirs(os.path.dirname(target))
802
import_version(target, version,
803
reuse_history_from=reuse_history_from,
804
standalone=standalone)
805
except pybaz.errors.ExecProblem,e:
806
if str(e).find('The requested revision cannot be built.') != -1:
808
"Skipping version %s as it cannot be built due"
809
" to a missing parent archive." % version)
813
if str(e).find('already exists, and the last revision ') != -1:
815
"Skipping version %s as it has had commits made"
816
" since it was converted to bzr." % version)
820
progress_bar.finished()
823
def map_namespace(a_version):
824
a_version = pybaz.Version("%s" % a_version)
825
parser = NameParser(a_version)
826
version = parser.get_version()
827
branch = parser.get_branch()
828
category = parser.get_category()
829
if branch is None or branch == '':
832
return "%s/%s" % (category, branch)
833
return "%s/%s/%s" % (category, version, branch)
836
def map_file_id(file_id):
837
"""Convert a baz file id to a bzr one."""
838
return file_id.replace('%', '%25').replace('/', '%2f')